From 31ec939da9a5d79c184e1f9ec7a64d0ff49d853f Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Sat, 27 Jun 2026 03:13:50 +0530
Subject: [PATCH 001/217] fix(model_resolver): supply dummy API key for LM
Studio when not provided
---
surfsense_backend/app/services/model_resolver.py | 4 ++++
.../unit/services/test_model_connections.py | 16 ++++++++++++++++
2 files changed, 20 insertions(+)
diff --git a/surfsense_backend/app/services/model_resolver.py b/surfsense_backend/app/services/model_resolver.py
index f31b658a4..590290b0c 100644
--- a/surfsense_backend/app/services/model_resolver.py
+++ b/surfsense_backend/app/services/model_resolver.py
@@ -59,6 +59,10 @@ def to_litellm(
kwargs: dict[str, Any] = {}
if api_key:
kwargs["api_key"] = api_key
+ elif provider == "lm_studio":
+ # LiteLLM's OpenAI-compatible adapter expects an api_key value even
+ # when LM Studio accepts unauthenticated local requests.
+ kwargs["api_key"] = "not-needed"
prefix = spec.litellm_prefix or str(provider)
model_string = f"{prefix}/{model_id}" if prefix else model_id
diff --git a/surfsense_backend/tests/unit/services/test_model_connections.py b/surfsense_backend/tests/unit/services/test_model_connections.py
index b4e7c18d7..efc056465 100644
--- a/surfsense_backend/tests/unit/services/test_model_connections.py
+++ b/surfsense_backend/tests/unit/services/test_model_connections.py
@@ -64,6 +64,22 @@ def test_openai_compatible_resolver_uses_explicit_api_base() -> None:
assert ensure_v1("http://example.com/v1") == "http://example.com/v1"
+def test_lm_studio_resolver_supplies_dummy_api_key_when_empty() -> None:
+ model, kwargs = to_litellm(
+ {
+ "provider": "lm_studio",
+ "base_url": "http://host.docker.internal:1234/v1",
+ "api_key": None,
+ "extra": {},
+ },
+ "tinyllama-1.1b-chat-v0.6",
+ )
+
+ assert model == "openai/tinyllama-1.1b-chat-v0.6"
+ assert kwargs["api_base"] == "http://host.docker.internal:1234/v1"
+ assert kwargs["api_key"] == "not-needed"
+
+
def test_ollama_resolver_uses_native_api_base() -> None:
model, kwargs = to_litellm(
{
From 0df51c60f372f7348382d5631512bf58c1918f3f Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Sat, 27 Jun 2026 03:46:15 +0530
Subject: [PATCH 002/217] refactor(models): remove manual model registration
backend
---
.../app/routes/model_connections_routes.py | 44 -------------------
surfsense_backend/app/schemas/__init__.py | 2 -
.../app/schemas/model_connections.py | 11 -----
.../app/services/model_connection_service.py | 8 ----
4 files changed, 65 deletions(-)
diff --git a/surfsense_backend/app/routes/model_connections_routes.py b/surfsense_backend/app/routes/model_connections_routes.py
index 84e9b830d..b7edfcc77 100644
--- a/surfsense_backend/app/routes/model_connections_routes.py
+++ b/surfsense_backend/app/routes/model_connections_routes.py
@@ -21,7 +21,6 @@ from app.schemas import (
ConnectionCreate,
ConnectionRead,
ConnectionUpdate,
- ModelCreate,
ModelPreviewRead,
ModelProviderRead,
ModelRead,
@@ -618,49 +617,6 @@ async def discover_connection_models(
return [_model_read(model) for model in conn.models]
-@router.post("/model-connections/{connection_id}/models", response_model=ModelRead)
-async def add_manual_model(
- connection_id: int,
- data: ModelCreate,
- session: AsyncSession = Depends(get_async_session),
- auth: AuthContext = Depends(get_auth_context),
-):
- conn = await _load_connection(session, connection_id)
- await _assert_connection_access(
- session, auth, conn, Permission.LLM_CONFIGS_UPDATE.value
- )
-
- model_id = data.model_id.strip()
- if not model_id:
- raise HTTPException(status_code=400, detail="model_id is required")
- if any(existing.model_id == model_id for existing in conn.models):
- raise HTTPException(
- status_code=400, detail="Model already exists on this connection"
- )
-
- capabilities = derive_capabilities(conn, model_id)
- model = Model(
- connection_id=conn.id,
- model_id=model_id,
- display_name=data.display_name or None,
- source=ModelSource.MANUAL,
- capabilities_override={},
- enabled=True,
- catalog={},
- )
- _apply_model_facts(model, capabilities)
- session.add(model)
- await session.commit()
- await session.refresh(model)
- conn = await _load_connection(session, connection_id)
- await _default_unset_roles(session, conn, list(conn.models))
- if conn.search_space_id is not None:
- await _clear_invalid_roles(session, conn.search_space_id)
- await session.commit()
- await session.refresh(model)
- return _model_read(model)
-
-
@router.patch(
"/model-connections/{connection_id}/models", response_model=list[ModelRead]
)
diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py
index f111f0226..5f5252fbd 100644
--- a/surfsense_backend/app/schemas/__init__.py
+++ b/surfsense_backend/app/schemas/__init__.py
@@ -43,7 +43,6 @@ from .model_connections import (
ConnectionCreate,
ConnectionRead,
ConnectionUpdate,
- ModelCreate,
ModelPreviewRead,
ModelProviderRead,
ModelRead,
@@ -203,7 +202,6 @@ __all__ = [
"MembershipRead",
"MembershipReadWithUser",
"MembershipUpdate",
- "ModelCreate",
"ModelPreviewRead",
"ModelProviderRead",
"ModelRead",
diff --git a/surfsense_backend/app/schemas/model_connections.py b/surfsense_backend/app/schemas/model_connections.py
index 0eec666c1..0f656dad3 100644
--- a/surfsense_backend/app/schemas/model_connections.py
+++ b/surfsense_backend/app/schemas/model_connections.py
@@ -93,17 +93,6 @@ class ConnectionUpdate(BaseModel):
enabled: bool | None = None
-class ModelCreate(BaseModel):
- """Manually register a model id on a connection.
-
- For providers without a usable ``/models`` endpoint (Perplexity, MiniMax,
- Azure deployments, etc.) or to pin a single model from a noisy provider.
- """
-
- model_id: str = Field(..., max_length=255)
- display_name: str | None = Field(None, max_length=255)
-
-
class ModelUpdate(BaseModel):
display_name: str | None = Field(None, max_length=255)
enabled: bool | None = None
diff --git a/surfsense_backend/app/services/model_connection_service.py b/surfsense_backend/app/services/model_connection_service.py
index cdfd1d725..be442bcb2 100644
--- a/surfsense_backend/app/services/model_connection_service.py
+++ b/surfsense_backend/app/services/model_connection_service.py
@@ -202,11 +202,6 @@ def _discovery_error_message(conn: Connection, exc: httpx.HTTPError) -> str:
return _docker_hint(base_url, exc)
-def _allowlist(conn: Connection) -> set[str]:
- raw = (conn.extra or {}).get("model_ids") or []
- return {str(item).strip() for item in raw if str(item).strip()}
-
-
def _litellm_info(model_string: str, model_id: str) -> dict[str, Any]:
with contextlib.suppress(Exception):
info = litellm.get_model_info(model=model_string)
@@ -438,7 +433,6 @@ async def _discover_bedrock_models(conn: Connection) -> list[dict[str, Any]]:
async def discover_models(conn: Connection) -> list[dict[str, Any]]:
- allowlist = _allowlist(conn)
spec = spec_for(conn.provider)
try:
@@ -459,8 +453,6 @@ async def discover_models(conn: Connection) -> list[dict[str, Any]]:
except httpx.HTTPError as exc:
raise ModelDiscoveryError(_discovery_error_message(conn, exc)) from exc
- if allowlist:
- results = [item for item in results if item["model_id"] in allowlist]
return results
From 3b507208bccf2ab1b149943059b9c51ace95c5d1 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Sat, 27 Jun 2026 03:46:24 +0530
Subject: [PATCH 003/217] refactor(models): remove manual model creation client
API
---
.../model-connections-mutation.atoms.ts | 23 +------------------
.../types/model-connections.types.ts | 6 -----
.../lib/apis/model-connections-api.service.ts | 15 ------------
3 files changed, 1 insertion(+), 43 deletions(-)
diff --git a/surfsense_web/atoms/model-connections/model-connections-mutation.atoms.ts b/surfsense_web/atoms/model-connections/model-connections-mutation.atoms.ts
index f00bf76f9..e9ff559c0 100644
--- a/surfsense_web/atoms/model-connections/model-connections-mutation.atoms.ts
+++ b/surfsense_web/atoms/model-connections/model-connections-mutation.atoms.ts
@@ -4,7 +4,6 @@ import type {
ConnectionCreateRequest,
ConnectionRead,
ConnectionUpdateRequest,
- ModelCreateRequest,
ModelPreviewRead,
ModelRead,
ModelRoles,
@@ -95,13 +94,7 @@ export const verifyModelConnectionMutationAtom = atomWithMutation((get) => {
if (result.ok) {
toast.success("Connection verified");
} else {
- // Non-fatal: many providers lack a /models endpoint yet still serve
- // chat. Guide the user to add model IDs manually instead of alarming.
- toast.warning(
- result.message
- ? `${result.message} Chat may still work — add model IDs manually.`
- : "Couldn't list models. Chat may still work — add model IDs manually."
- );
+ toast.warning(result.message || "Couldn't verify this connection.");
}
invalidateModelConnections(searchSpaceId);
},
@@ -148,20 +141,6 @@ export const testPreviewModelMutationAtom = atomWithMutation(() => {
};
});
-export const addManualModelMutationAtom = atomWithMutation((get) => {
- const searchSpaceId = Number(get(activeSearchSpaceIdAtom));
- return {
- mutationKey: ["models", "add-manual"],
- mutationFn: ({ connectionId, data }: { connectionId: number; data: ModelCreateRequest }) =>
- modelConnectionsApiService.addManualModel(connectionId, data),
- onSuccess: () => {
- toast.success("Model added");
- invalidateModelConnections(searchSpaceId);
- },
- onError: (error: Error) => toast.error(error.message || "Failed to add model"),
- };
-});
-
export const updateModelMutationAtom = atomWithMutation((get) => {
const searchSpaceId = Number(get(activeSearchSpaceIdAtom));
return {
diff --git a/surfsense_web/contracts/types/model-connections.types.ts b/surfsense_web/contracts/types/model-connections.types.ts
index 0f0c7591e..73dff23eb 100644
--- a/surfsense_web/contracts/types/model-connections.types.ts
+++ b/surfsense_web/contracts/types/model-connections.types.ts
@@ -74,11 +74,6 @@ export const connectionUpdateRequest = z.object({
enabled: z.boolean().optional(),
});
-export const modelCreateRequest = z.object({
- model_id: z.string().min(1),
- display_name: z.string().nullable().optional(),
-});
-
export const modelUpdateRequest = z.object({
display_name: z.string().nullable().optional(),
enabled: z.boolean().optional(),
@@ -135,7 +130,6 @@ export type ConnectionRead = z.infer;
export type ConnectionCreateRequest = z.infer;
export type ModelTestPreviewRequest = z.infer;
export type ConnectionUpdateRequest = z.infer;
-export type ModelCreateRequest = z.infer;
export type ModelUpdateRequest = z.infer;
export type ModelsBulkUpdateRequest = z.infer;
export type ModelRoles = z.infer;
diff --git a/surfsense_web/lib/apis/model-connections-api.service.ts b/surfsense_web/lib/apis/model-connections-api.service.ts
index c69bcbef2..6083642f5 100644
--- a/surfsense_web/lib/apis/model-connections-api.service.ts
+++ b/surfsense_web/lib/apis/model-connections-api.service.ts
@@ -8,7 +8,6 @@ import {
connectionUpdateRequest,
type GlobalLlmConfigStatus,
globalLlmConfigStatus,
- type ModelCreateRequest,
type ModelPreviewRead,
type ModelProviderRead,
type ModelRead,
@@ -16,7 +15,6 @@ import {
type ModelsBulkUpdateRequest,
type ModelTestPreviewRequest,
type ModelUpdateRequest,
- modelCreateRequest,
modelListResponse,
modelPreviewListResponse,
modelProviderListResponse,
@@ -112,19 +110,6 @@ class ModelConnectionsApiService {
});
};
- addManualModel = async (
- connectionId: number,
- request: ModelCreateRequest
- ): Promise => {
- const parsed = modelCreateRequest.safeParse(request);
- if (!parsed.success) {
- throw new ValidationError(parsed.error.issues.map((issue) => issue.message).join(", "));
- }
- return baseApiService.post(`/api/v1/model-connections/${connectionId}/models`, modelRead, {
- body: parsed.data,
- });
- };
-
updateModel = async (id: number, request: ModelUpdateRequest): Promise => {
const parsed = modelUpdateRequest.safeParse(request);
if (!parsed.success) {
From 13a2637df7038213f3a4df13aec136758466cd8d Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Sat, 27 Jun 2026 03:46:36 +0530
Subject: [PATCH 004/217] refactor(models): simplify provider model selection
UI
---
.../bedrock-connect-form.tsx | 2 +-
.../connection-settings-dialog.tsx | 48 -------------------
.../model-provider-connections-panel.tsx | 17 -------
.../models-selection-panel.tsx | 48 -------------------
.../provider-connect-dialog.tsx | 27 ++++++-----
.../model-connections/vertex-connect-form.tsx | 2 +-
6 files changed, 16 insertions(+), 128 deletions(-)
diff --git a/surfsense_web/components/settings/model-connections/bedrock-connect-form.tsx b/surfsense_web/components/settings/model-connections/bedrock-connect-form.tsx
index f76308421..0da545bd8 100644
--- a/surfsense_web/components/settings/model-connections/bedrock-connect-form.tsx
+++ b/surfsense_web/components/settings/model-connections/bedrock-connect-form.tsx
@@ -113,7 +113,7 @@ export function BedrockConnectForm({ onDraftChange }: ProviderConnectFormProps)
) : null}
- Add Bedrock model IDs from the provider's settings after connecting.
+ After entering credentials, refresh models to discover the Bedrock catalog for this region.
);
diff --git a/surfsense_web/components/settings/model-connections/connection-settings-dialog.tsx b/surfsense_web/components/settings/model-connections/connection-settings-dialog.tsx
index 1f16c3bd0..0120e28ed 100644
--- a/surfsense_web/components/settings/model-connections/connection-settings-dialog.tsx
+++ b/surfsense_web/components/settings/model-connections/connection-settings-dialog.tsx
@@ -2,7 +2,6 @@ import { useAtomValue } from "jotai";
import { Eye, EyeOff, Settings } from "lucide-react";
import { useMemo, useState } from "react";
import {
- addManualModelMutationAtom,
bulkUpdateModelsMutationAtom,
discoverConnectionModelsMutationAtom,
testPreviewModelMutationAtom,
@@ -50,26 +49,17 @@ export function ConnectionSettingsDialog({
const discoverModels = useAtomValue(discoverConnectionModelsMutationAtom);
const testPreviewModel = useAtomValue(testPreviewModelMutationAtom);
const updateConnection = useAtomValue(updateModelConnectionMutationAtom);
- const addManualModel = useAtomValue(addManualModelMutationAtom);
const bulkUpdateModels = useAtomValue(bulkUpdateModelsMutationAtom);
- const allowlist = Array.isArray(connection.extra?.model_ids)
- ? (connection.extra.model_ids as string[])
- : [];
const [isOpen, setIsOpen] = useState(false);
const [baseUrlDraft, setBaseUrlDraft] = useState(connection.base_url ?? "");
const [apiKeyDraft, setApiKeyDraft] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
- const [allowlistText, setAllowlistText] = useState(allowlist.join(", "));
const [isSavingConnectionSettings, setIsSavingConnectionSettings] = useState(false);
const [draftEnabledModelIds, setDraftEnabledModelIds] = useState(() =>
enabledModelIds(connection.models)
);
- const isLocal =
- connection.provider === "ollama_chat" ||
- connection.provider === "lm_studio" ||
- !connection.base_url?.startsWith("https");
const hasConnectionChanges =
baseUrlDraft.trim() !== (connection.base_url ?? "") ||
apiKeyDraft.trim() !== (connection.api_key ?? "");
@@ -93,7 +83,6 @@ export function ConnectionSettingsDialog({
setBaseUrlDraft(connection.base_url ?? "");
setApiKeyDraft(connection.api_key ?? "");
setShowApiKey(false);
- setAllowlistText(allowlist.join(", "));
setIsSavingConnectionSettings(false);
setDraftEnabledModelIds(enabledModelIds(connection.models));
}
@@ -168,17 +157,6 @@ export function ConnectionSettingsDialog({
}
}
- function saveAllowlist() {
- const ids = allowlistText
- .split(",")
- .map((value) => value.trim())
- .filter(Boolean);
- updateConnection.mutate({
- id: connection.id,
- data: { extra: { ...(connection.extra ?? {}), model_ids: ids } },
- });
- }
-
function handleToggleModel(model: SelectableModel, enabled: boolean) {
if (typeof model.id !== "number") return;
const modelId = model.id;
@@ -276,41 +254,15 @@ export function ConnectionSettingsDialog({
- {!isLocal ? (
-
-
Model IDs filter (optional)
-
- setAllowlistText(event.target.value)}
- placeholder="Comma-separated, e.g. anthropic/claude-sonnet-4-5, google/gemini-2.5-pro"
- />
-
- Save filter
-
-
-
- Leave empty to discover all models. Recommended for providers with large catalogs.
-
-
- ) : null}
-
discoverModels.mutate(connection.id)}
- onAddManual={(modelId) =>
- addManualModel.mutate({
- connectionId: connection.id,
- data: { model_id: modelId },
- })
- }
onToggleModel={handleToggleModel}
onBulkToggle={handleBulkToggle}
/>
diff --git a/surfsense_web/components/settings/model-connections/model-provider-connections-panel.tsx b/surfsense_web/components/settings/model-connections/model-provider-connections-panel.tsx
index a703ab1c8..13fd39894 100644
--- a/surfsense_web/components/settings/model-connections/model-provider-connections-panel.tsx
+++ b/surfsense_web/components/settings/model-connections/model-provider-connections-panel.tsx
@@ -201,22 +201,6 @@ export function ModelProviderConnectionsPanel({
);
}
- function addConnectModel(modelId: string) {
- setConnectModels((current) => {
- if (current.some((model) => model.model_id === modelId)) return current;
- return [
- ...current,
- {
- model_id: modelId,
- display_name: modelId,
- source: "MANUAL",
- enabled: true,
- metadata: {},
- },
- ];
- });
- }
-
function toggleConnectModel(model: SelectableModel, enabled: boolean) {
setConnectModels((current) =>
current.map((item) => (item.model_id === model.model_id ? { ...item, enabled } : item))
@@ -277,7 +261,6 @@ export function ModelProviderConnectionsPanel({
previewModels={connectModels}
isPreviewingModels={previewModels.isPending}
onPreviewModels={refreshConnectModels}
- onAddPreviewModel={addConnectModel}
onTogglePreviewModel={toggleConnectModel}
onBulkTogglePreviewModels={bulkToggleConnectModels}
/>
diff --git a/surfsense_web/components/settings/model-connections/models-selection-panel.tsx b/surfsense_web/components/settings/model-connections/models-selection-panel.tsx
index 3c6990afb..6aa0c2661 100644
--- a/surfsense_web/components/settings/model-connections/models-selection-panel.tsx
+++ b/surfsense_web/components/settings/model-connections/models-selection-panel.tsx
@@ -1,10 +1,7 @@
import { RefreshCw } from "lucide-react";
import { useState } from "react";
-import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
-import { Input } from "@/components/ui/input";
-import { Spinner } from "@/components/ui/spinner";
import {
capability,
capabilityLabels,
@@ -18,14 +15,11 @@ interface ModelsSelectionPanelProps {
models: SelectableModel[];
description?: string;
emptyMessage?: string;
- manualInputPlaceholder?: string;
refreshLabel?: string;
isRefreshing?: boolean;
- isAddingManual?: boolean;
isUpdatingModel?: boolean;
isBulkUpdating?: boolean;
onRefresh?: () => void;
- onAddManual?: (modelId: string) => void;
onToggleModel?: (model: SelectableModel, enabled: boolean) => void;
onBulkToggle?: (models: SelectableModel[], enabled: boolean) => void;
}
@@ -34,18 +28,14 @@ export function ModelsSelectionPanel({
models,
description = "Select models to make available for this provider.",
emptyMessage = "No models available.",
- manualInputPlaceholder = "Add a model ID manually",
refreshLabel = "Refresh models",
isRefreshing = false,
- isAddingManual = false,
isUpdatingModel = false,
isBulkUpdating = false,
onRefresh,
- onAddManual,
onToggleModel,
onBulkToggle,
}: ModelsSelectionPanelProps) {
- const [manualModelId, setManualModelId] = useState("");
const [modelFilter, setModelFilter] = useState(null);
const filteredModels = modelFilter
@@ -54,13 +44,6 @@ export function ModelsSelectionPanel({
const allFilteredModelsEnabled =
filteredModels.length > 0 && filteredModels.every((model) => model.enabled);
- function addModel() {
- const modelId = manualModelId.trim();
- if (!modelId || !onAddManual) return;
- onAddManual(modelId);
- setManualModelId("");
- }
-
function toggleFilteredModels() {
const nextEnabled = !allFilteredModelsEnabled;
const changedModels = filteredModels.filter((model) => model.enabled !== nextEnabled);
@@ -100,32 +83,6 @@ export function ModelsSelectionPanel({
- {onAddManual ? (
-
- setManualModelId(event.target.value)}
- onKeyDown={(event) => {
- if (event.key === "Enter") {
- event.preventDefault();
- addModel();
- }
- }}
- placeholder={manualInputPlaceholder}
- />
-
- Add model
- {isAddingManual ? : null}
-
-
- ) : null}
-
{models.length > 0 ? (
Filter models
@@ -179,11 +136,6 @@ export function ModelsSelectionPanel({
{modelLabel(model)}
- {model.source === "MANUAL" ? (
-
- manual
-
- ) : null}
{capabilityLabels(model) || "No discovered capabilities"}
diff --git a/surfsense_web/components/settings/model-connections/provider-connect-dialog.tsx b/surfsense_web/components/settings/model-connections/provider-connect-dialog.tsx
index 2eee2cf8c..0b795064d 100644
--- a/surfsense_web/components/settings/model-connections/provider-connect-dialog.tsx
+++ b/surfsense_web/components/settings/model-connections/provider-connect-dialog.tsx
@@ -33,7 +33,6 @@ interface ProviderConnectDialogProps {
previewModels?: SelectableModel[];
isPreviewingModels?: boolean;
onPreviewModels?: (draft: ConnectionDraft) => void;
- onAddPreviewModel?: (modelId: string) => void;
onTogglePreviewModel?: (model: SelectableModel, enabled: boolean) => void;
onBulkTogglePreviewModels?: (models: SelectableModel[], enabled: boolean) => void;
}
@@ -53,7 +52,6 @@ export function ProviderConnectDialog({
previewModels = [],
isPreviewingModels = false,
onPreviewModels,
- onAddPreviewModel,
onTogglePreviewModel,
onBulkTogglePreviewModels,
}: ProviderConnectDialogProps) {
@@ -130,18 +128,21 @@ export function ProviderConnectDialog({
)}
-
+ {!isAzure ? (
+ <>
+
- onPreviewModels?.(currentDraft) : undefined}
- onAddManual={onAddPreviewModel}
- onToggleModel={onTogglePreviewModel}
- onBulkToggle={onBulkTogglePreviewModels}
- />
+ onPreviewModels?.(currentDraft) : undefined}
+ onToggleModel={onTogglePreviewModel}
+ onBulkToggle={onBulkTogglePreviewModels}
+ />
+ >
+ ) : null}
onOpenChange(false)}
diff --git a/surfsense_web/components/settings/model-connections/vertex-connect-form.tsx b/surfsense_web/components/settings/model-connections/vertex-connect-form.tsx
index 1027742bc..3701ea14e 100644
--- a/surfsense_web/components/settings/model-connections/vertex-connect-form.tsx
+++ b/surfsense_web/components/settings/model-connections/vertex-connect-form.tsx
@@ -111,7 +111,7 @@ export function VertexConnectForm({ onDraftChange }: ProviderConnectFormProps) {
)}
- Add Vertex AI model IDs from the provider's settings after connecting.
+ SurfSense will show supported Vertex AI models from the provider catalog.
);
From 4ceb5827f14f6f3358924145779fb1383e5935a4 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 3 Jul 2026 10:27:48 +0530
Subject: [PATCH 005/217] feat(models): add refresh control enhancements to
model selection and provider connect dialogs
- Introduced `isRefreshDisabled` prop to `ModelsSelectionPanel` to control refresh button state.
- Updated `ProviderConnectDialog` to conditionally enable refresh functionality based on model support and submission status.
---
.../settings/model-connections/models-selection-panel.tsx | 4 +++-
.../settings/model-connections/provider-connect-dialog.tsx | 6 ++++--
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/surfsense_web/components/settings/model-connections/models-selection-panel.tsx b/surfsense_web/components/settings/model-connections/models-selection-panel.tsx
index 6aa0c2661..b6258b7b2 100644
--- a/surfsense_web/components/settings/model-connections/models-selection-panel.tsx
+++ b/surfsense_web/components/settings/model-connections/models-selection-panel.tsx
@@ -17,6 +17,7 @@ interface ModelsSelectionPanelProps {
emptyMessage?: string;
refreshLabel?: string;
isRefreshing?: boolean;
+ isRefreshDisabled?: boolean;
isUpdatingModel?: boolean;
isBulkUpdating?: boolean;
onRefresh?: () => void;
@@ -30,6 +31,7 @@ export function ModelsSelectionPanel({
emptyMessage = "No models available.",
refreshLabel = "Refresh models",
isRefreshing = false,
+ isRefreshDisabled = false,
isUpdatingModel = false,
isBulkUpdating = false,
onRefresh,
@@ -74,7 +76,7 @@ export function ModelsSelectionPanel({
size="icon"
type="button"
onClick={onRefresh}
- disabled={isRefreshing}
+ disabled={isRefreshing || isRefreshDisabled}
aria-label={refreshLabel}
>
diff --git a/surfsense_web/components/settings/model-connections/provider-connect-dialog.tsx b/surfsense_web/components/settings/model-connections/provider-connect-dialog.tsx
index 0b795064d..2fba3bcbf 100644
--- a/surfsense_web/components/settings/model-connections/provider-connect-dialog.tsx
+++ b/surfsense_web/components/settings/model-connections/provider-connect-dialog.tsx
@@ -92,7 +92,8 @@ export function ProviderConnectDialog({
return "Select the models to enable for this provider";
})();
- const canRefreshModels = !isAzure && !isVertex && (!isBedrock || canSubmit);
+ const supportsModelRefresh = !isAzure && !isVertex;
+ const canRefreshModels = supportsModelRefresh && canSubmit;
const hasEnabledModel =
previewModels.some((model) => model.enabled) || Boolean(currentDraft.seedModelId);
const canConnect = canSubmit && hasEnabledModel;
@@ -136,8 +137,9 @@ export function ProviderConnectDialog({
models={previewModels}
description={modelDescription}
isRefreshing={isPreviewingModels}
+ isRefreshDisabled={!canRefreshModels}
refreshLabel={`Refresh ${meta.name} models`}
- onRefresh={canRefreshModels ? () => onPreviewModels?.(currentDraft) : undefined}
+ onRefresh={supportsModelRefresh ? () => onPreviewModels?.(currentDraft) : undefined}
onToggleModel={onTogglePreviewModel}
onBulkToggle={onBulkTogglePreviewModels}
/>
From 249bba08868b81d3e7c0b2796a47f2c10c89e86e Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:41:54 +0200
Subject: [PATCH 006/217] feat: indeed scraper input/output schemas
---
.../platforms/indeed_jobs/schemas.py | 122 ++++++++++++++++++
1 file changed, 122 insertions(+)
create mode 100644 surfsense_backend/app/proprietary/platforms/indeed_jobs/schemas.py
diff --git a/surfsense_backend/app/proprietary/platforms/indeed_jobs/schemas.py b/surfsense_backend/app/proprietary/platforms/indeed_jobs/schemas.py
new file mode 100644
index 000000000..27be45d9a
--- /dev/null
+++ b/surfsense_backend/app/proprietary/platforms/indeed_jobs/schemas.py
@@ -0,0 +1,122 @@
+# ruff: noqa: N815 - field names intentionally use the public camelCase API
+"""Input/output models for the Indeed scraper.
+
+Anonymous scraper: there is no auth field. Fields absent from a listing (full
+description, benefits) stay ``None``/``[]`` until a detail fetch fills them.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+IndeedSort = Literal["relevance", "date"]
+IndeedJobType = Literal[
+ "fulltime",
+ "parttime",
+ "contract",
+ "internship",
+ "temporary",
+ "permanent",
+ "seasonal",
+ "freelance",
+]
+IndeedLevel = Literal["entry_level", "mid_level", "senior_level"]
+IndeedRemote = Literal["remote", "hybrid"]
+SalaryPeriod = Literal["hour", "day", "week", "month", "year"]
+
+
+class StartUrl(BaseModel):
+ """A direct URL entry; extra keys ignored."""
+
+ model_config = ConfigDict(extra="allow")
+
+ url: str
+
+
+class IndeedScrapeInput(BaseModel):
+ """Indeed scraper input. Caps are collector policy, enforced by ``scrape_indeed``."""
+
+ model_config = ConfigDict(extra="allow")
+
+ # Discovery: direct URLs and/or search queries.
+ startUrls: list[StartUrl] = Field(default_factory=list)
+ queries: list[str] = Field(default_factory=list)
+
+ # Search parameters applied to ``queries``.
+ country: str = "us"
+ location: str | None = None
+ radius: int | None = None
+ jobType: IndeedJobType | None = None
+ level: IndeedLevel | None = None
+ remote: IndeedRemote | None = None
+ fromDays: int | None = None
+ sort: IndeedSort = "relevance"
+
+ # Fetch each job's detail page for the full description.
+ scrapeJobDetails: bool = False
+
+ maxItems: int = Field(default=25, ge=0)
+ maxItemsPerQuery: int = Field(default=25, ge=0)
+
+
+class Salary(BaseModel):
+ """Salary block; fields are ``None`` when Indeed omits pay."""
+
+ model_config = ConfigDict(extra="allow")
+
+ salaryText: str | None = None
+ salaryMin: float | None = None
+ salaryMax: float | None = None
+ currency: str | None = None
+ period: SalaryPeriod | None = None
+ isEstimated: bool | None = None
+
+
+class IndeedItem(BaseModel):
+ """One job posting. ``extra="allow"`` keeps the contract additive."""
+
+ model_config = ConfigDict(extra="allow")
+
+ jobKey: str | None = None
+ title: str | None = None
+ jobUrl: str | None = None
+ applyUrl: str | None = None
+
+ company: str | None = None
+ companyUrl: str | None = None
+ companyRating: float | None = None
+ companyReviewCount: int | None = None
+
+ formattedLocation: str | None = None
+ city: str | None = None
+ state: str | None = None
+ postalCode: str | None = None
+ country: str | None = None
+ isRemote: bool | None = None
+ remoteType: str | None = None
+
+ jobTypes: list[str] = Field(default_factory=list)
+ salary: Salary = Field(default_factory=Salary)
+ benefits: list[str] = Field(default_factory=list)
+
+ descriptionText: str | None = None
+ descriptionHtml: str | None = None
+
+ sponsored: bool | None = None
+ isNew: bool | None = None
+ urgentlyHiring: bool | None = None
+ expired: bool | None = None
+ indeedApplyEnabled: bool | None = None
+
+ age: str | None = None
+ datePublished: str | None = None
+ createdAt: str | None = None
+ scrapedAt: str | None = None
+
+ source: str = "indeed"
+
+ def to_output(self) -> dict[str, Any]:
+ """Serialize to the flat output dict, keeping extras."""
+ return self.model_dump(exclude_none=False)
From 01c3f097e6bfc53fa0eb961f7555566052f2ca26 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:41:54 +0200
Subject: [PATCH 007/217] feat: indeed jobcards blob extraction and job parser
---
.../platforms/indeed_jobs/parsers.py | 228 ++++++++++++++++++
1 file changed, 228 insertions(+)
create mode 100644 surfsense_backend/app/proprietary/platforms/indeed_jobs/parsers.py
diff --git a/surfsense_backend/app/proprietary/platforms/indeed_jobs/parsers.py b/surfsense_backend/app/proprietary/platforms/indeed_jobs/parsers.py
new file mode 100644
index 000000000..ab2b97cf9
--- /dev/null
+++ b/surfsense_backend/app/proprietary/platforms/indeed_jobs/parsers.py
@@ -0,0 +1,228 @@
+"""Pure HTML/JSON -> item mapping for the Indeed scraper.
+
+I/O-free and deterministic so it can be unit-tested against captured fixtures;
+the orchestrator stamps ``scrapedAt``.
+
+Indeed embeds job data as a JS assignment::
+
+ window.mosaic.providerData["mosaic-provider-jobcards"]={"metaData":{...}};
+
+The literal recurs hundreds of times in the page, so :func:`extract_jobcards_blob`
+anchors on the assignment (``...]=``) and brace-matches the balanced object.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from html import unescape
+from re import sub as _re_sub
+from typing import Any
+
+_DEFAULT_BASE = "https://www.indeed.com"
+
+_JOBCARDS_ANCHOR = 'window.mosaic.providerData["mosaic-provider-jobcards"]='
+
+# Indeed's extractedSalary.type -> our SalaryPeriod.
+_SALARY_PERIODS = {
+ "HOURLY": "hour",
+ "DAILY": "day",
+ "WEEKLY": "week",
+ "MONTHLY": "month",
+ "YEARLY": "year",
+}
+
+
+def _brace_match(text: str, start: int) -> str | None:
+ """Return the balanced ``{...}``/``[...]`` blob at ``text[start]``, quote-aware."""
+ open_ch = text[start] if start < len(text) else ""
+ close_ch = {"[": "]", "{": "}"}.get(open_ch)
+ if close_ch is None:
+ return None
+ depth = 0
+ i = start
+ n = len(text)
+ while i < n:
+ ch = text[i]
+ if ch == open_ch:
+ depth += 1
+ elif ch == close_ch:
+ depth -= 1
+ if depth == 0:
+ return text[start : i + 1]
+ elif ch == '"':
+ i += 1
+ while i < n and text[i] != '"':
+ if text[i] == "\\":
+ i += 1
+ i += 1
+ i += 1
+ return None
+
+
+def extract_jobcards_blob(html: str) -> dict | None:
+ """Decode the ``mosaic-provider-jobcards`` assignment, or ``None`` if absent."""
+ import json
+
+ idx = html.find(_JOBCARDS_ANCHOR)
+ if idx == -1:
+ return None
+ brace = html.find("{", idx + len(_JOBCARDS_ANCHOR))
+ if brace == -1:
+ return None
+ blob = _brace_match(html, brace)
+ if not blob:
+ return None
+ try:
+ data = json.loads(blob)
+ except ValueError:
+ return None
+ return data if isinstance(data, dict) else None
+
+
+def job_results(blob: dict | None) -> list[dict[str, Any]]:
+ """Return the raw job records from a decoded blob."""
+ if not isinstance(blob, dict):
+ return []
+ results = (
+ blob.get("metaData", {}).get("mosaicProviderJobCardsModel", {}).get("results")
+ )
+ if not isinstance(results, list):
+ return []
+ return [r for r in results if isinstance(r, dict)]
+
+
+def _utc_from_ms(value: Any) -> str | None:
+ """Epoch milliseconds -> millisecond ISO string."""
+ if isinstance(value, bool) or not isinstance(value, int | float):
+ return None
+ dt = datetime.fromtimestamp(float(value) / 1000.0, tz=UTC)
+ return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
+
+
+def _int(value: Any) -> int | None:
+ """Coerce to int, dropping bools."""
+ if isinstance(value, bool):
+ return None
+ if isinstance(value, int):
+ return value
+ if isinstance(value, float):
+ return int(value)
+ return None
+
+
+def _abs_url(path: Any, base_url: str) -> str | None:
+ """Resolve an Indeed-relative path against ``base_url``; keep absolute URLs."""
+ if not isinstance(path, str) or not path:
+ return None
+ if path.startswith("http"):
+ return path
+ return f"{base_url}{path if path.startswith('/') else '/' + path}"
+
+
+def _clean_snippet(snippet: Any) -> str | None:
+ """Strip tags and decode entities into plain text."""
+ if not isinstance(snippet, str) or not snippet:
+ return None
+ text = _re_sub(r"<[^>]+>", " ", snippet)
+ text = unescape(text)
+ return _re_sub(r"\s+", " ", text).strip() or None
+
+
+def _taxonomy(raw: dict[str, Any]) -> dict[str, list[str]]:
+ """Flatten ``taxonomyAttributes`` into ``{group label: [attribute labels]}``."""
+ out: dict[str, list[str]] = {}
+ for group in raw.get("taxonomyAttributes") or []:
+ if not isinstance(group, dict):
+ continue
+ label = group.get("label")
+ attrs = group.get("attributes")
+ if isinstance(label, str) and isinstance(attrs, list):
+ out[label] = [
+ a["label"]
+ for a in attrs
+ if isinstance(a, dict) and isinstance(a.get("label"), str)
+ ]
+ return out
+
+
+def _job_types(raw: dict[str, Any], taxo: dict[str, list[str]]) -> list[str]:
+ """Job types from ``jobTypes`` then the taxonomy, deduped and order-stable."""
+ seen: dict[str, None] = {}
+ for jt in raw.get("jobTypes") or []:
+ if isinstance(jt, str):
+ seen.setdefault(jt, None)
+ for label in ("job-types", "job-types-cc"):
+ for jt in taxo.get(label, []):
+ seen.setdefault(jt, None)
+ return list(seen)
+
+
+def _salary(raw: dict[str, Any]) -> dict[str, Any]:
+ """Flatten salary from ``salarySnippet`` (text) + ``extractedSalary`` (bounds)."""
+ snippet = raw.get("salarySnippet") or {}
+ extracted = raw.get("extractedSalary") or {}
+ estimated = raw.get("estimatedSalary") or {}
+ source = extracted or estimated
+ text = snippet.get("text") if isinstance(snippet, dict) else None
+ return {
+ "salaryText": text if isinstance(text, str) else None,
+ "salaryMin": source.get("min") if isinstance(source, dict) else None,
+ "salaryMax": source.get("max") if isinstance(source, dict) else None,
+ "currency": snippet.get("currency") if isinstance(snippet, dict) else None,
+ "period": _SALARY_PERIODS.get(
+ source.get("type") if isinstance(source, dict) else None
+ ),
+ "isEstimated": bool(estimated) and not extracted,
+ }
+
+
+def _is_remote(raw: dict[str, Any], taxo: dict[str, list[str]]) -> bool:
+ """Resolve remote/hybrid across Indeed's several signals."""
+ if raw.get("remoteLocation") is True:
+ return True
+ if isinstance(raw.get("remoteWorkModel"), dict):
+ return True
+ return bool(taxo.get("remote"))
+
+
+def parse_job(raw: dict[str, Any], *, base_url: str = _DEFAULT_BASE) -> dict[str, Any]:
+ """Map one raw ``results[]`` record to a flat item dict.
+
+ ``base_url`` is the country domain the record came from, so job and company
+ URLs resolve to the right host.
+ """
+ taxo = _taxonomy(raw)
+ job_key = raw.get("jobkey")
+ remote_model = raw.get("remoteWorkModel")
+ return {
+ "jobKey": job_key if isinstance(job_key, str) else None,
+ "title": raw.get("displayTitle") or raw.get("title"),
+ "jobUrl": f"{base_url}/viewjob?jk={job_key}" if job_key else None,
+ "applyUrl": raw.get("thirdPartyApplyUrl") or None,
+ "company": raw.get("company") or raw.get("truncatedCompany"),
+ "companyUrl": _abs_url(raw.get("companyOverviewLink"), base_url),
+ "companyRating": raw.get("companyRating"),
+ "companyReviewCount": _int(raw.get("companyReviewCount")),
+ "formattedLocation": raw.get("formattedLocation"),
+ "city": raw.get("jobLocationCity"),
+ "state": raw.get("jobLocationState"),
+ "postalCode": raw.get("jobLocationPostal"),
+ "country": raw.get("country"),
+ "isRemote": _is_remote(raw, taxo),
+ "remoteType": remote_model.get("type")
+ if isinstance(remote_model, dict)
+ else None,
+ "jobTypes": _job_types(raw, taxo),
+ "salary": _salary(raw),
+ "benefits": taxo.get("benefits", []),
+ "descriptionText": _clean_snippet(raw.get("snippet")),
+ "descriptionHtml": None,
+ "sponsored": raw.get("sponsored"),
+ "isNew": raw.get("newJob"),
+ "urgentlyHiring": raw.get("urgentlyHiring"),
+ "expired": raw.get("expired"),
+ "indeedApplyEnabled": raw.get("indeedApplyEnabled"),
+ "age": raw.get("formattedRelativeTime"),
+ "datePublished": _utc_from_ms(raw.get("pubDate")),
+ "createdAt": _utc_from_ms(raw.get("createDate")),
+ }
From d87c86dcc41bd4d8a4013d2ff954f31bc86b9663 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:41:54 +0200
Subject: [PATCH 008/217] feat: indeed url classifier and search url builder
---
.../platforms/indeed_jobs/url_resolver.py | 124 ++++++++++++++++++
1 file changed, 124 insertions(+)
create mode 100644 surfsense_backend/app/proprietary/platforms/indeed_jobs/url_resolver.py
diff --git a/surfsense_backend/app/proprietary/platforms/indeed_jobs/url_resolver.py b/surfsense_backend/app/proprietary/platforms/indeed_jobs/url_resolver.py
new file mode 100644
index 000000000..c69d3fe19
--- /dev/null
+++ b/surfsense_backend/app/proprietary/platforms/indeed_jobs/url_resolver.py
@@ -0,0 +1,124 @@
+"""Classify Indeed URLs and build search URLs.
+
+Recognizes search pages (``/jobs?q=&l=``), company pages (``/cmp//jobs``),
+and single jobs (``/viewjob?jk=``); other hosts resolve to ``None``. Also owns
+the country->domain map so classification and URL building share one source.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Literal
+from urllib.parse import parse_qs, urlencode, urlparse
+
+ResolvedKind = Literal["search", "company", "job"]
+
+# Locale subdomains that deviate from the ISO code; others map to .indeed.com.
+_DOMAIN_OVERRIDES = {"us": "www", "gb": "uk"}
+
+_JT_VALUES = frozenset(
+ {
+ "fulltime",
+ "parttime",
+ "contract",
+ "internship",
+ "temporary",
+ "permanent",
+ "seasonal",
+ "freelance",
+ }
+)
+
+
+@dataclass(frozen=True)
+class ResolvedUrl:
+ kind: ResolvedKind
+ value: str # search query, company slug, or job key
+ url: str
+ domain: str
+ location: str | None = None
+ params: dict[str, str] = field(default_factory=dict)
+
+
+def _is_indeed_host(hostname: str | None) -> bool:
+ if not hostname:
+ return False
+ h = hostname.lower()
+ return h == "indeed.com" or h.endswith(".indeed.com")
+
+
+def country_domain(country: str) -> str:
+ """Country code -> Indeed host, e.g. ``us`` -> ``www.indeed.com``."""
+ cc = (country or "us").strip().lower()
+ return f"{_DOMAIN_OVERRIDES.get(cc, cc)}.indeed.com"
+
+
+def resolve_url(url: str) -> ResolvedUrl | None:
+ """Classify an Indeed URL into a scrape job, or ``None`` if unrecognized."""
+ parsed = urlparse(url)
+ if not _is_indeed_host(parsed.hostname):
+ return None
+ domain = parsed.hostname or "www.indeed.com"
+ path = (parsed.path or "").rstrip("/")
+ query = parse_qs(parsed.query)
+ segments = [s for s in path.split("/") if s]
+
+ # /viewjob?jk=
+ if path.endswith("/viewjob") or segments[:1] == ["viewjob"]:
+ jk = query.get("jk", [None])[0]
+ return ResolvedUrl("job", jk, url, domain) if jk else None
+
+ # /cmp//jobs
+ if segments[:1] == ["cmp"] and "jobs" in segments and len(segments) >= 2:
+ return ResolvedUrl("company", segments[1], url, domain)
+
+ # /jobs?q=&l=
+ if path.endswith("/jobs") or segments[-1:] == ["jobs"]:
+ q = query.get("q", [""])[0]
+ loc = query.get("l", [None])[0]
+ extra = {
+ k: v[0]
+ for k, v in query.items()
+ if k in ("radius", "sort", "fromage", "jt", "explvl") and v
+ }
+ return ResolvedUrl("search", q, url, domain, location=loc, params=extra)
+
+ return None
+
+
+def build_search_url(
+ query: str,
+ *,
+ country: str = "us",
+ location: str | None = None,
+ radius: int | None = None,
+ job_type: str | None = None,
+ level: str | None = None,
+ remote: str | None = None,
+ from_days: int | None = None,
+ sort: str = "relevance",
+ start: int = 0,
+) -> str:
+ """Build an Indeed ``/jobs`` search URL.
+
+ Remote/hybrid is passed as a query keyword; Indeed's structured ``sc``
+ attribute codes rotate and aren't stable to hardcode.
+ """
+ domain = country_domain(country)
+ q = f"{query} {remote}".strip() if remote else query
+ params: dict[str, str] = {"q": q}
+ if location:
+ params["l"] = location
+ if radius is not None:
+ params["radius"] = str(radius)
+ if job_type in _JT_VALUES:
+ params["jt"] = job_type # type: ignore[assignment]
+ if level:
+ params["explvl"] = level
+ if from_days is not None:
+ params["fromage"] = str(from_days)
+ if sort == "date":
+ params["sort"] = "date"
+ if start:
+ params["start"] = str(start)
+ return f"https://{domain}/jobs?{urlencode(params)}"
From 44f9f0301c81f7c6e778e8985b178f667112ab67 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:41:54 +0200
Subject: [PATCH 009/217] feat: indeed warmed browser session with
rotate-on-block
---
.../platforms/indeed_jobs/fetch.py | 184 ++++++++++++++++++
1 file changed, 184 insertions(+)
create mode 100644 surfsense_backend/app/proprietary/platforms/indeed_jobs/fetch.py
diff --git a/surfsense_backend/app/proprietary/platforms/indeed_jobs/fetch.py b/surfsense_backend/app/proprietary/platforms/indeed_jobs/fetch.py
new file mode 100644
index 000000000..9f913a579
--- /dev/null
+++ b/surfsense_backend/app/proprietary/platforms/indeed_jobs/fetch.py
@@ -0,0 +1,184 @@
+"""Browser-session fetch seam for the Indeed scraper.
+
+Indeed fronts its origin with Cloudflare plus an anonymous-bot check that bounces
+cold sessions to ``secure.indeed.com/auth``. The working recipe: a persistent
+camoufox session that solves Cloudflare, warms on the domain home page, then
+navigates to ``/jobs`` in the same context so the clearance carries.
+
+:class:`IndeedSession` warms per domain once, retries a blocked page on a fresh
+residential IP, and caps each navigation with a hard timeout so a stuck solve
+can't stall a run. All egress is through the residential proxy.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections.abc import Awaitable, Callable
+from contextlib import asynccontextmanager, suppress
+from datetime import UTC, datetime
+from typing import Any, Protocol
+from urllib.parse import urlparse
+
+from app.utils.proxy import get_proxy_url
+
+logger = logging.getLogger(__name__)
+
+
+class IndeedAccessBlockedError(RuntimeError):
+ """Every rotated IP was bounced to Indeed's security wall."""
+
+
+# Per navigation; a stuck Cloudflare solve otherwise hangs the whole run.
+_PAGE_TIMEOUT_S = 75.0
+# Browser-internal timeout; kept above the page timeout so ours fires first.
+_SESSION_TIMEOUT_MS = 90_000
+_MAX_ROTATIONS = 3
+
+# Markers of a Cloudflare / security-check interstitial served instead of jobs.
+_BLOCK_MARKERS = (
+ "secure.indeed.com",
+ "bot-detection",
+ "security check",
+ "challenge-platform",
+ "just a moment",
+ "verify you are human",
+ "hcaptcha",
+)
+
+
+def now_iso() -> str:
+ """UTC timestamp in the millisecond ISO shape used by scraper output."""
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
+
+
+class _Session(Protocol):
+ """Minimal browser-session surface used here (real or fake)."""
+
+ async def start(self) -> Any: ...
+ async def fetch(self, url: str, **kwargs: Any) -> Any: ...
+ async def close(self) -> Any: ...
+
+
+def _default_session_factory() -> _Session:
+ """Build a proxied, Cloudflare-solving camoufox session.
+
+ ``disable_resources`` skips images/fonts/media; job data is inline in the
+ document, so this only trims bandwidth.
+ """
+ from scrapling.fetchers import AsyncStealthySession
+
+ return AsyncStealthySession(
+ headless=True,
+ solve_cloudflare=True,
+ network_idle=True,
+ block_webrtc=True,
+ disable_resources=True,
+ timeout=_SESSION_TIMEOUT_MS,
+ proxy=get_proxy_url(),
+ )
+
+
+def _html(page: Any) -> str:
+ """Best-effort HTML body across scrapling response shapes."""
+ for attr in ("html_content", "body", "text"):
+ val = getattr(page, attr, None)
+ if isinstance(val, bytes):
+ val = val.decode("utf-8", "replace")
+ if isinstance(val, str) and val:
+ return val
+ return ""
+
+
+def _looks_blocked(html: str, final_url: str) -> bool:
+ """Whether a response is an interstitial rather than a real page."""
+ if not html:
+ return True
+ haystack = (final_url + " " + html[:6000]).lower()
+ return any(marker in haystack for marker in _BLOCK_MARKERS)
+
+
+class IndeedSession:
+ """One warmed browser session that rotates its exit IP when blocked."""
+
+ def __init__(
+ self, session_factory: Callable[[], _Session] = _default_session_factory
+ ) -> None:
+ self._factory = session_factory
+ self._session: _Session | None = None
+ self._warmed: set[str] = set()
+ self.rotations = 0
+
+ async def start(self) -> None:
+ self._session = self._factory()
+ await self._session.start()
+
+ async def close(self) -> None:
+ if self._session is not None:
+ with suppress(Exception):
+ await self._session.close()
+ self._session = None
+ self._warmed.clear()
+
+ async def _rotate(self) -> None:
+ """Drop the session for a fresh exit IP; clears warmed domains."""
+ await self.close()
+ self.rotations += 1
+ await self.start()
+ logger.info("[indeed] rotated session (rotation #%d)", self.rotations)
+
+ async def _timed_fetch(self, url: str, **kwargs: Any) -> Any:
+ assert self._session is not None
+ coro: Awaitable[Any] = self._session.fetch(url, **kwargs)
+ return await asyncio.wait_for(coro, timeout=_PAGE_TIMEOUT_S)
+
+ async def _ensure_warm(self, domain: str) -> None:
+ """Land on the domain home with a Google referer before scraping it."""
+ if domain in self._warmed:
+ return
+ with suppress(Exception):
+ await self._timed_fetch(f"https://{domain}/", google_search=True)
+ self._warmed.add(domain)
+
+ async def fetch_html(self, url: str) -> str:
+ """Return a search/company/job page's HTML through the warmed session.
+
+ Rotates the IP and re-warms on a security-wall bounce or timeout; raises
+ :class:`IndeedAccessBlockedError` once every rotation is exhausted.
+ """
+ if self._session is None:
+ await self.start()
+ domain = urlparse(url).hostname or "www.indeed.com"
+ attempt = 0
+ while True:
+ try:
+ await self._ensure_warm(domain)
+ page = await self._timed_fetch(url)
+ html = _html(page)
+ if not _looks_blocked(html, str(getattr(page, "url", "") or "")):
+ return html
+ logger.info("[indeed] blocked on %s", url)
+ except TimeoutError:
+ logger.warning("[indeed] fetch timed out on %s", url)
+ except Exception as e:
+ logger.warning("[indeed] fetch failed on %s: %s", url, e)
+
+ if attempt >= _MAX_ROTATIONS:
+ raise IndeedAccessBlockedError(
+ f"Indeed refused {url} on {attempt} rotated IPs"
+ )
+ attempt += 1
+ await self._rotate()
+
+
+@asynccontextmanager
+async def open_session(
+ session_factory: Callable[[], _Session] = _default_session_factory,
+):
+ """Open an :class:`IndeedSession` and guarantee teardown."""
+ session = IndeedSession(session_factory)
+ await session.start()
+ try:
+ yield session
+ finally:
+ await session.close()
From 824041ef7fef6c3ec09c2509098ee21e059ef405 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:41:54 +0200
Subject: [PATCH 010/217] feat: indeed scraper orchestrator with pagination and
dedupe
---
.../platforms/indeed_jobs/scraper.py | 156 ++++++++++++++++++
1 file changed, 156 insertions(+)
create mode 100644 surfsense_backend/app/proprietary/platforms/indeed_jobs/scraper.py
diff --git a/surfsense_backend/app/proprietary/platforms/indeed_jobs/scraper.py b/surfsense_backend/app/proprietary/platforms/indeed_jobs/scraper.py
new file mode 100644
index 000000000..4f66f2ebf
--- /dev/null
+++ b/surfsense_backend/app/proprietary/platforms/indeed_jobs/scraper.py
@@ -0,0 +1,156 @@
+"""Orchestrator for the Indeed scraper.
+
+:func:`iter_indeed` streams flat job items from one warmed browser session:
+``startUrls`` (search/company pages) or ``queries`` are paged by the ``start``
+offset, deduped by ``jobKey``, and stopped on the first page that adds nothing.
+:func:`scrape_indeed` collects the stream under a caller ``limit``.
+
+Targets run sequentially on a single session — a browser per target would cost
+far more than the sequential paging it would save.
+"""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import AsyncIterator
+from typing import Any
+from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
+
+from .fetch import IndeedSession, now_iso, open_session
+from .parsers import extract_jobcards_blob, job_results, parse_job
+from .schemas import IndeedItem, IndeedScrapeInput
+from .url_resolver import build_search_url, resolve_url
+
+logger = logging.getLogger(__name__)
+
+__all__ = ["iter_indeed", "scrape_indeed"]
+
+# Indeed's search pagination increments the ``start`` offset by 10.
+_PAGE_STEP = 10
+# Indeed stops serving useful results past ~1000; cap pages well before that.
+_MAX_PAGES = 10
+
+
+def _emit(partial: dict[str, Any]) -> dict[str, Any]:
+ """Stamp ``scrapedAt`` and normalize through the output model."""
+ return IndeedItem(**{**partial, "scrapedAt": now_iso()}).to_output()
+
+
+def _with_start(url: str, start: int) -> str:
+ """Return ``url`` with its ``start`` query param set (removed when 0)."""
+ parsed = urlparse(url)
+ params = [(k, v) for k, v in parse_qsl(parsed.query) if k != "start"]
+ if start:
+ params.append(("start", str(start)))
+ return urlunparse(parsed._replace(query=urlencode(params)))
+
+
+async def _paginate(
+ session: IndeedSession, first_url: str, *, domain: str, max_items: int
+) -> AsyncIterator[dict[str, Any]]:
+ """Yield items across pages of one search/company URL, deduped by ``jobKey``."""
+ if max_items <= 0:
+ return
+ base_url = f"https://{domain}"
+ seen: set[str] = set()
+ emitted = 0
+ for page in range(_MAX_PAGES):
+ html = await session.fetch_html(_with_start(first_url, page * _PAGE_STEP))
+ raws = job_results(extract_jobcards_blob(html))
+ if not raws:
+ return
+ added = 0
+ for raw in raws:
+ item = parse_job(raw, base_url=base_url)
+ job_key = item.get("jobKey")
+ if isinstance(job_key, str):
+ if job_key in seen:
+ continue
+ seen.add(job_key)
+ yield _emit(item)
+ emitted += 1
+ added += 1
+ if emitted >= max_items:
+ return
+ if added == 0: # a whole page of duplicates: end of useful results
+ return
+
+
+def _targets(input_model: IndeedScrapeInput) -> list[tuple[str, str]]:
+ """Resolve inputs to ``(url, domain)`` search/company targets.
+
+ ``startUrls`` take precedence over ``queries``. Job (``/viewjob``) URLs are
+ skipped here; detail enrichment is a separate flow.
+ """
+ if input_model.startUrls:
+ out: list[tuple[str, str]] = []
+ for entry in input_model.startUrls:
+ resolved = resolve_url(entry.url)
+ if resolved is None or resolved.kind == "job":
+ logger.warning("[indeed] skipping unsupported URL: %s", entry.url)
+ continue
+ out.append((resolved.url, resolved.domain))
+ return out
+
+ domain = None
+ urls: list[tuple[str, str]] = []
+ for query in input_model.queries:
+ url = build_search_url(
+ query,
+ country=input_model.country,
+ location=input_model.location,
+ radius=input_model.radius,
+ job_type=input_model.jobType,
+ level=input_model.level,
+ remote=input_model.remote,
+ from_days=input_model.fromDays,
+ sort=input_model.sort,
+ )
+ domain = domain or urlparse(url).hostname or "www.indeed.com"
+ urls.append((url, domain))
+ return urls
+
+
+async def iter_indeed(
+ input_model: IndeedScrapeInput, session: IndeedSession
+) -> AsyncIterator[dict[str, Any]]:
+ """Stream flat job items for every target, deduped by ``jobKey`` across all."""
+ global_seen: set[str] = set()
+ for url, domain in _targets(input_model):
+ async for item in _paginate(
+ session, url, domain=domain, max_items=input_model.maxItemsPerQuery
+ ):
+ job_key = item.get("jobKey")
+ if isinstance(job_key, str):
+ if job_key in global_seen:
+ continue
+ global_seen.add(job_key)
+ yield item
+
+
+async def scrape_indeed(
+ input_model: IndeedScrapeInput,
+ *,
+ limit: int | None = None,
+ session: IndeedSession | None = None,
+) -> list[dict[str, Any]]:
+ """Collect :func:`iter_indeed` into a list under an optional ``limit``.
+
+ Opens a warmed session when one is not supplied. ``limit`` is a request-time
+ guard, not a ceiling baked into the stream.
+ """
+ from app.capabilities.core.progress import emit_progress
+
+ async def _collect(sess: IndeedSession) -> list[dict[str, Any]]:
+ results: list[dict[str, Any]] = []
+ async for item in iter_indeed(input_model, sess):
+ results.append(item)
+ emit_progress("scraping", current=len(results), total=limit, unit="item")
+ if limit is not None and len(results) >= limit:
+ break
+ return results
+
+ if session is not None:
+ return await _collect(session)
+ async with open_session() as sess:
+ return await _collect(sess)
From 1d367148d009bf3eab4a3fc320eb7c1d7038e90a Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:41:54 +0200
Subject: [PATCH 011/217] feat: indeed_jobs package exports
---
.../proprietary/platforms/indeed_jobs/__init__.py | 13 +++++++++++++
1 file changed, 13 insertions(+)
create mode 100644 surfsense_backend/app/proprietary/platforms/indeed_jobs/__init__.py
diff --git a/surfsense_backend/app/proprietary/platforms/indeed_jobs/__init__.py b/surfsense_backend/app/proprietary/platforms/indeed_jobs/__init__.py
new file mode 100644
index 000000000..eb59671d7
--- /dev/null
+++ b/surfsense_backend/app/proprietary/platforms/indeed_jobs/__init__.py
@@ -0,0 +1,13 @@
+"""Platform-native Indeed jobs scraper (anonymous, warmed browser session)."""
+
+from .fetch import IndeedAccessBlockedError
+from .schemas import IndeedItem, IndeedScrapeInput
+from .scraper import iter_indeed, scrape_indeed
+
+__all__ = [
+ "IndeedAccessBlockedError",
+ "IndeedItem",
+ "IndeedScrapeInput",
+ "iter_indeed",
+ "scrape_indeed",
+]
From 794b127136b411060a8aba037ad40ae8280ac0ae Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:42:00 +0200
Subject: [PATCH 012/217] test: indeed_jobs test package
---
surfsense_backend/tests/unit/platforms/indeed_jobs/__init__.py | 0
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 surfsense_backend/tests/unit/platforms/indeed_jobs/__init__.py
diff --git a/surfsense_backend/tests/unit/platforms/indeed_jobs/__init__.py b/surfsense_backend/tests/unit/platforms/indeed_jobs/__init__.py
new file mode 100644
index 000000000..e69de29bb
From 3d6da91bf36112bf0995628395ff7625ce061bb7 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:42:00 +0200
Subject: [PATCH 013/217] test: indeed trimmed jobcards fixture
---
.../indeed_jobs/fixtures/sample_jobcards.json | 1633 +++++++++++++++++
1 file changed, 1633 insertions(+)
create mode 100644 surfsense_backend/tests/unit/platforms/indeed_jobs/fixtures/sample_jobcards.json
diff --git a/surfsense_backend/tests/unit/platforms/indeed_jobs/fixtures/sample_jobcards.json b/surfsense_backend/tests/unit/platforms/indeed_jobs/fixtures/sample_jobcards.json
new file mode 100644
index 000000000..9faa61ef7
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/indeed_jobs/fixtures/sample_jobcards.json
@@ -0,0 +1,1633 @@
+{
+ "metaData": {
+ "mosaicProviderJobCardsModel": {
+ "results": [
+ {
+ "adBlob": "-6NYlbfkN0AfEq5j7saNRG5aU_mvVTuWOB9eeCeHIli2OtKfZJ_lU68CQK3WO6fr1U8P9BG_8N1lyYEY1nzdWnmTq7hlQAqMT4EZoUMRYAQrNEOj5nb4ZE_F_ZCNcUyWwXqUnsb3IFHbnllXq0f5pYhflUexN3LD6mf9j8U6fPKOFmTQuul154CxQeo8aExEApf08b2iFfAx7Eci6fsR4i5wlRjvKA5EumK1U1Eauxzua2oUUaLKdAW9137H0CYgdcoAMF84PbbBCj5eW5R0p7Tfgi9NmbhJFRaEZv-u8JEg9U1zcdmaIh6RuPDU7bjQIuak5MO3DwaEJlHXEg4v7-XpR0uTAehVEkbNEFQdl0EwMnI32HxxwGJ_r7Xy7viI3kh6lEdrpZeF1ca18i9FlF_nce_H7BdzO2qmsM11dLrzIwGnipd9-NCmqh-jZJbJLooc2Yl3D8379SRHWpbfMz_a9aa1x_miN7s9YFssX_W8wrIqe3eHIaV-pRMzXMJYIxrcyPztopcC5udht92l9RFkJ8GjCK-zMHwiJRuCIqjML2cMsQblSxIpv5pb9M88dlUMlDd6dMpQBDYRrFe1PYyRu_QTWc_ggES6VZu7PJ7BcMPSFZ7cSuzuPT49lMRw7lYs3P1aakqoiFThrgWJAWATgalPtzZy754uEQG6ez5Gh-KW-nvDhDDfUS-fhvIMcDFftBf6gyU6_wEuH9GI7sOTR8b7jnHS285ryLd212LhBy0GJjliQdMxRmWK0qDqFr8z9q7Ufi9AHPafSIWTFw==",
+ "adId": "464623129",
+ "advn": "9533474731693465",
+ "appliedOrGreater": false,
+ "applyCount": -1,
+ "assistedApply": false,
+ "autoSourcerJob": false,
+ "bidPosition": 1,
+ "blobKey": "SoBj6_M3hwflOvyN8p0LbzkdCdPP",
+ "company": "TherapyNotes.com",
+ "companyBrandingAttributes": {
+ "brandingReasons": [
+ "PAID_BRANDING"
+ ],
+ "brandingReasonsAsString": [
+ "PAID_BRANDING"
+ ],
+ "headerImageUrl": "https://d2q79iu7y748jz.cloudfront.net/s/_headerimage/1960x400/9d05368a0956c3266ea4291e95d34331",
+ "logoUrl": "https://d2q79iu7y748jz.cloudfront.net/s/_squarelogo/256x256/eac29e66f882990ae917c959915eb291",
+ "paidBrandingDealIds": [
+ "58664",
+ "95349"
+ ],
+ "showJobBranding": true,
+ "shownForBrandedJobPackage": false
+ },
+ "companyIdEncrypted": "d10e9cc0e75a9595",
+ "companyOverviewLink": "/cmp/Therapynotes",
+ "companyOverviewLinkCampaignId": "serp-linkcompanyname-content",
+ "companyRating": 3.8,
+ "companyReviewCount": 30,
+ "companyReviewLink": "/cmp/Therapynotes/reviews",
+ "companyReviewLinkCampaignId": "cmplinktst2",
+ "country": "US",
+ "createDate": 1774276267415,
+ "d2iEnabled": false,
+ "displayTitle": "Software Developer",
+ "dradisJob": false,
+ "employerAssistEnabled": false,
+ "employerResponseTime": 1,
+ "employerResponsive": true,
+ "encryptedFccompanyId": "c5ef9b9375a8bbf1",
+ "encryptedResultData": "VwIPTVJ1cTn5AN7Q-tSqGRXGNe2wB2UYx73qSczFnGU",
+ "enhancedAttributesModel": {},
+ "enticers": [],
+ "expired": false,
+ "extractTrackingUrls": "",
+ "extractedSalary": {
+ "max": 115000,
+ "min": 65000,
+ "type": "YEARLY"
+ },
+ "fccompanyId": -1,
+ "featuredCompanyAttributes": {},
+ "featuredEmployer": false,
+ "featuredEmployerCandidate": false,
+ "feedId": 822778,
+ "formattedLocation": "Philadelphia, PA",
+ "formattedRelativeTime": "30+ days ago",
+ "gatedVjp": false,
+ "hideMetaData": false,
+ "highVolumeHiringModel": {
+ "highVolumeHiring": false
+ },
+ "hiringEventJob": false,
+ "homepageJobFeedSectionId": "0",
+ "indeedApplyEnabled": true,
+ "indeedApplyFinishAppUrlEnabled": false,
+ "indeedApplyResumeType": "required",
+ "indeedApplyable": true,
+ "isJobVisited": false,
+ "isMobileThirdPartyApplyable": false,
+ "isNoResumeJob": false,
+ "isSubsidiaryJob": false,
+ "isTopRatedEmployer": false,
+ "jobCardRequirementsModel": {
+ "additionalRequirementsCount": 0,
+ "jobOnlyRequirements": [],
+ "jobTagRequirements": [],
+ "requirementsHeaderShown": false,
+ "screenerQuestionRequirements": []
+ },
+ "jobFlairPackageEnabled": false,
+ "jobLocationCity": "Philadelphia",
+ "jobLocationState": "PA",
+ "jobSeekerMatchSummaryModel": {
+ "sortedEntityDisplayText": [],
+ "sortedMatchingEntityDisplayText": [],
+ "sortedMisMatchingEntityDisplayText": [
+ ".NET Core",
+ "Agile software development",
+ "Bachelor's degree",
+ "Communication skills",
+ "Computer Science",
+ "Computer science",
+ "Design (software development lifecycle)",
+ "Developing and maintaining backend systems",
+ "Entity Framework",
+ "Event-driven architecture",
+ "Front-end component implementation",
+ "HTML",
+ "JavaScript frameworks",
+ "Planning (software development lifecycle)",
+ "PostgreSQL",
+ "Project development phase management",
+ "Providing code feedback",
+ "Responsive design",
+ "SASS/LESS",
+ "SQL databases",
+ "Scalability",
+ "Scalable systems",
+ "Software Engineering",
+ "Software engineering",
+ "TypeScript",
+ "Web applications",
+ "Web services design"
+ ],
+ "taxoEntityMatchesNegative": [
+ {
+ "displayText": "SASS/LESS",
+ "id": "",
+ "rawName": "SASS/LESS",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "FE6SC"
+ },
+ {
+ "displayText": "Computer science",
+ "id": "",
+ "rawName": "Computer science",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "4E4WW"
+ },
+ {
+ "displayText": "JavaScript frameworks",
+ "id": "",
+ "rawName": "JavaScript frameworks",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "EUPT5"
+ },
+ {
+ "displayText": "HTML",
+ "id": "",
+ "rawName": "HTML",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "Y7U37"
+ },
+ {
+ "displayText": "Developing and maintaining backend systems",
+ "id": "",
+ "rawName": "Developing and maintaining backend systems",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "Q3QNF"
+ },
+ {
+ "displayText": "Design (software development lifecycle)",
+ "id": "",
+ "rawName": "Design (software development lifecycle)",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "XA6CW"
+ },
+ {
+ "displayText": "Responsive design",
+ "id": "",
+ "rawName": "Responsive design",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "36ZHE"
+ },
+ {
+ "displayText": "Scalable systems",
+ "id": "",
+ "rawName": "Scalable systems",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "DP9CS"
+ },
+ {
+ "displayText": "SQL databases",
+ "id": "",
+ "rawName": "SQL databases",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "7G8UN"
+ },
+ {
+ "displayText": "Software Engineering",
+ "id": "",
+ "rawName": "Software Engineering",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "7F6H9"
+ },
+ {
+ "displayText": "Web applications",
+ "id": "",
+ "rawName": "Web applications",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "QM3S8"
+ },
+ {
+ "displayText": "Entity Framework",
+ "id": "",
+ "rawName": "Entity Framework",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "X5HXD"
+ },
+ {
+ "displayText": "Computer Science",
+ "id": "",
+ "rawName": "Computer Science",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "6XNCP"
+ },
+ {
+ "displayText": "Providing code feedback",
+ "id": "",
+ "rawName": "Providing code feedback",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "Z4F5N"
+ },
+ {
+ "displayText": "PostgreSQL",
+ "id": "",
+ "rawName": "PostgreSQL",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "JCKB4"
+ },
+ {
+ "displayText": ".NET Core",
+ "id": "",
+ "rawName": ".NET Core",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "AGW49"
+ },
+ {
+ "displayText": "TypeScript",
+ "id": "",
+ "rawName": "TypeScript",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "WD7PP"
+ },
+ {
+ "displayText": "Scalability",
+ "id": "",
+ "rawName": "Scalability",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "PYTWK"
+ },
+ {
+ "displayText": "Agile software development",
+ "id": "",
+ "rawName": "Agile software development",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "QWGDE"
+ },
+ {
+ "displayText": "Web services design",
+ "id": "",
+ "rawName": "Web services design",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "ZN5SR"
+ },
+ {
+ "displayText": "Project development phase management",
+ "id": "",
+ "rawName": "Project development phase management",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "4UEV6"
+ },
+ {
+ "displayText": "Bachelor's degree",
+ "id": "",
+ "rawName": "Bachelor's degree",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "HFDVW"
+ },
+ {
+ "displayText": "Software engineering",
+ "id": "",
+ "rawName": "Software engineering",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "4R4AM"
+ },
+ {
+ "displayText": "Event-driven architecture",
+ "id": "",
+ "rawName": "Event-driven architecture",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "G82AR"
+ },
+ {
+ "displayText": "Front-end component implementation",
+ "id": "",
+ "rawName": "Front-end component implementation",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "RP4CB"
+ },
+ {
+ "displayText": "Communication skills",
+ "id": "",
+ "rawName": "Communication skills",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "WSBNK"
+ },
+ {
+ "displayText": "Planning (software development lifecycle)",
+ "id": "",
+ "rawName": "Planning (software development lifecycle)",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "X9WB7"
+ }
+ ],
+ "taxoEntityMatchesPositive": [],
+ "trafficLight": "yellow"
+ },
+ "jobTypes": [],
+ "jobkey": "7b993d015f4b257a",
+ "jsiEnabled": false,
+ "link": "/pagead/clk?mo=r&ad=-6NYlbfkN0AfEq5j7saNRG5aU_mvVTuWOB9eeCeHIli2OtKfZJ_lU68CQK3WO6fr1U8P9BG_8N1lyYEY1nzdWnmTq7hlQAqMT4EZoUMRYAQrNEOj5nb4ZE_F_ZCNcUyWwXqUnsb3IFHbnllXq0f5pYhflUexN3LD6mf9j8U6fPKOFmTQuul154CxQeo8aExEApf08b2iFfAx7Eci6fsR4i5wlRjvKA5EumK1U1Eauxzua2oUUaLKdAW9137H0CYgdcoAMF84PbbBCj5eW5R0p7Tfgi9NmbhJFRaEZv-u8JEg9U1zcdmaIh6RuPDU7bjQIuak5MO3DwaEJlHXEg4v7-XpR0uTAehVEkbNEFQdl0EwMnI32HxxwGJ_r7Xy7viI3kh6lEdrpZeF1ca18i9FlF_nce_H7BdzO2qmsM11dLrzIwGnipd9-NCmqh-jZJbJLooc2Yl3D8379SRHWpbfMz_a9aa1x_miN7s9YFssX_W8wrIqe3eHIaV-pRMzXMJYIxrcyPztopcC5udht92l9RFkJ8GjCK-zMHwiJRuCIqjML2cMsQblSxIpv5pb9M88dlUMlDd6dMpQBDYRrFe1PYyRu_QTWc_ggES6VZu7PJ7BcMPSFZ7cSuzuPT49lMRw7lYs3P1aakqoiFThrgWJAWATgalPtzZy754uEQG6ez5Gh-KW-nvDhDDfUS-fhvIMcDFftBf6gyU6_wEuH9GI7sOTR8b7jnHS285ryLd212LhBy0GJjliQdMxRmWK0qDqFr8z9q7Ufi9AHPafSIWTFw==&xkcb=SoBj6_M3hwflOvyN8p0LbzkdCdPP&p=0&camk=C3EPSzFlQw-JWkl1H7Zuog==&vjs=3",
+ "locationCount": 1,
+ "minimumCount": 1,
+ "mobtk": "1jtgbqmn921k6001",
+ "moreLocUrl": "/jobs?q=software+engineer&nl=&l=Remote&radius=50&jtid=6804610d02b67694&jcid=d10e9cc0e75a9595&grp=tcl",
+ "mouseDownHandlerOption": {
+ "adId": 464623129,
+ "advn": "9533474731693465",
+ "extractTrackingUrls": [],
+ "from": "vjs",
+ "jobKey": "7b993d015f4b257a",
+ "link": "/pagead/clk?mo=r&ad=-6NYlbfkN0AfEq5j7saNRG5aU_mvVTuWOB9eeCeHIli2OtKfZJ_lU68CQK3WO6fr1U8P9BG_8N1lyYEY1nzdWnmTq7hlQAqMT4EZoUMRYAQrNEOj5nb4ZE_F_ZCNcUyWwXqUnsb3IFHbnllXq0f5pYhflUexN3LD6mf9j8U6fPKOFmTQuul154CxQeo8aExEApf08b2iFfAx7Eci6fsR4i5wlRjvKA5EumK1U1Eauxzua2oUUaLKdAW9137H0CYgdcoAMF84PbbBCj5eW5R0p7Tfgi9NmbhJFRaEZv-u8JEg9U1zcdmaIh6RuPDU7bjQIuak5MO3DwaEJlHXEg4v7-XpR0uTAehVEkbNEFQdl0EwMnI32HxxwGJ_r7Xy7viI3kh6lEdrpZeF1ca18i9FlF_nce_H7BdzO2qmsM11dLrzIwGnipd9-NCmqh-jZJbJLooc2Yl3D8379SRHWpbfMz_a9aa1x_miN7s9YFssX_W8wrIqe3eHIaV-pRMzXMJYIxrcyPztopcC5udht92l9RFkJ8GjCK-zMHwiJRuCIqjML2cMsQblSxIpv5pb9M88dlUMlDd6dMpQBDYRrFe1PYyRu_QTWc_ggES6VZu7PJ7BcMPSFZ7cSuzuPT49lMRw7lYs3P1aakqoiFThrgWJAWATgalPtzZy754uEQG6ez5Gh-KW-nvDhDDfUS-fhvIMcDFftBf6gyU6_wEuH9GI7sOTR8b7jnHS285ryLd212LhBy0GJjliQdMxRmWK0qDqFr8z9q7Ufi9AHPafSIWTFw==&xkcb=SoBj6_M3hwflOvyN8p0LbzkdCdPP&p=0&camk=C3EPSzFlQw-JWkl1H7Zuog==&vjs=3",
+ "tk": "1jtgbqmn921k6001"
+ },
+ "newJob": false,
+ "normTitle": "Software Engineer",
+ "numHires": 0,
+ "openInterviewsInterviewsOnTheSpot": false,
+ "openInterviewsJob": false,
+ "openInterviewsOffersOnTheSpot": false,
+ "openInterviewsPhoneJob": false,
+ "organicApplyStartCount": 6979,
+ "overrideIndeedApplyText": true,
+ "packageTier": "NONE",
+ "preciseLocationModel": {
+ "obfuscateLocation": false,
+ "overrideJCMPreciseLocationModel": true
+ },
+ "pubDate": 1774242000000,
+ "rankedBenefits": {
+ "GENERIC": [
+ "YQ98H",
+ "RZAT2",
+ "FQJ2X",
+ "CFRGS",
+ "4C2ZW"
+ ],
+ "OCCUPATION_TFIDF": [
+ "4C2ZW",
+ "CFRGS",
+ "FQJ2X",
+ "RZAT2",
+ "YQ98H"
+ ]
+ },
+ "rankingScoresModel": {
+ "bid": 123996,
+ "bidPosition": 1,
+ "eApply": 0.020990705,
+ "eAttainability": 0.08190364,
+ "eQualified": 0
+ },
+ "recommendationReasonModel": {
+ "reason": null
+ },
+ "redirectToThirdPartySite": false,
+ "remoteLocation": false,
+ "remoteWorkModel": {
+ "inlineText": true,
+ "text": "Remote",
+ "type": "REMOTE_ALWAYS"
+ },
+ "resultBeforeExpansion": false,
+ "resumeMatch": false,
+ "salarySnippet": {
+ "currency": "USD",
+ "salaryTextFormatted": false,
+ "source": "EXTRACTION",
+ "text": "$65,000 - $115,000 a year"
+ },
+ "saved": false,
+ "savedApplication": false,
+ "screenerQuestionsURL": "iqp://JOB/69c158406b0a663247e222ee?sourceUrl=iqp%3A%2F%2FINDEXEDJOB%2F10074441540%3Fcountry%3DUS&advertiserId=32113445&aggJobId=10074441540&country=US&language=en",
+ "searchUID": "1jtgbqmn921k6001",
+ "showCommutePromo": false,
+ "showEarlyApply": false,
+ "showJobType": false,
+ "showRelativeDate": true,
+ "showSponsoredLabel": false,
+ "showStrongerAppliedLabel": false,
+ "smartFillEnabled": false,
+ "smbD2iEnabled": false,
+ "snippet": "\n We are looking for a passionate engineer skilled in building scalable and responsive web applications and services using Angular and ASP. \n ",
+ "sourceId": 3368918,
+ "sponsored": true,
+ "taxoAttributes": [
+ "Profit sharing"
+ ],
+ "taxoAttributesDisplayLimit": 3,
+ "taxoLogAttributes": [
+ "4C2ZW"
+ ],
+ "taxonomyAttributes": [
+ {
+ "attributes": [
+ {
+ "label": "Full-time",
+ "suid": "CF3CP"
+ }
+ ],
+ "label": "job-types"
+ },
+ {
+ "attributes": [],
+ "label": "shifts"
+ },
+ {
+ "attributes": [
+ {
+ "label": "Remote",
+ "suid": "DSQF7"
+ }
+ ],
+ "label": "remote"
+ },
+ {
+ "attributes": [
+ {
+ "label": "Dental insurance",
+ "suid": "FQJ2X"
+ },
+ {
+ "label": "Disability insurance",
+ "suid": "CFRGS"
+ },
+ {
+ "label": "Retirement plan",
+ "suid": "YQ98H"
+ },
+ {
+ "label": "Vision insurance",
+ "suid": "RZAT2"
+ },
+ {
+ "label": "Profit sharing",
+ "suid": "4C2ZW"
+ }
+ ],
+ "label": "benefits"
+ },
+ {
+ "attributes": [
+ {
+ "label": "Full-time",
+ "suid": "CF3CP"
+ }
+ ],
+ "label": "job-types-cc"
+ },
+ {
+ "attributes": [],
+ "label": "schedules"
+ }
+ ],
+ "thirdPartyApplyUrl": "http://www.indeed.com//applystart?jk=7b993d015f4b257a&from=jobsearch&mvj=0&jobsearchTk=1jtgbqmn921k6001&spon=1&adid=464623129&ad=-6NYlbfkN0AfEq5j7saNRG5aU_mvVTuWOB9eeCeHIli2OtKfZJ_lU68CQK3WO6fr1U8P9BG_8N1lyYEY1nzdWnmTq7hlQAqMT4EZoUMRYAQrNEOj5nb4ZE_F_ZCNcUyWwXqUnsb3IFHbnllXq0f5pYhflUexN3LD6mf9j8U6fPKOFmTQuul154CxQeo8aExEApf08b2iFfAx7Eci6fsR4i5wlRjvKA5EumK1U1Eauxzua2oUUaLKdAW9137H0CYgdcoAMF84PbbBCj5eW5R0p7Tfgi9NmbhJFRaEZv-u8JEg9U1zcdmaIh6RuPDU7bjQIuak5MO3DwaEJlHXEg4v7-XpR0uTAehVEkbNEFQdl0EwMnI32HxxwGJ_r7Xy7viI3kh6lEdrpZeF1ca18i9FlF_nce_H7BdzO2qmsM11dLrzIwGnipd9-NCmqh-jZJbJLooc2Yl3D8379SRHWpbfMz_a9aa1x_miN7s9YFssX_W8wrIqe3eHIaV-pRMzXMJYIxrcyPztopcC5udht92l9RFkJ8GjCK-zMHwiJRuCIqjML2cMsQblSxIpv5pb9M88dlUMlDd6dMpQBDYRrFe1PYyRu_QTWc_ggES6VZu7PJ7BcMPSFZ7cSuzuPT49lMRw7lYs3P1aakqoiFThrgWJAWATgalPtzZy754uEQG6ez5Gh-KW-nvDhDDfUS-fhvIMcDFftBf6gyU6_wEuH9GI7sOTR8b7jnHS285ryLd212LhBy0GJjliQdMxRmWK0qDqFr8z9q7Ufi9AHPafSIWTFw%3D%3D&xkcb=SoBj6_M3hwflOvyN8p0LbzkdCdPP&sjdu=Xbbwb0dbVlAPs0TIG7JumEX25uTOF1Oj2WnXk16AywHWA2PUcrZQMHMXHdXk2eTvbAixkyUF3j-OOmJlYeYtIjeDAvq7rEASgcC0zRMWutBvuyCgKBpkTMsm3zcuFYK1uIzH-8PzvPSrzMEzGtRTg5jv-iXDq5yVFQaIv8kgOWkCg9YikGZNJgeIivR2iCfdxWXtDkF3x7BjrIiI14W0QsCgmbR1Dg8qS-_CO9vND2NLAJoHK8df0UY8vlo7n1P7mzaDYyEyfb0INk3TiuEWMemQii8wUThkfLKJY9xDEST1O0JfmjdhQ3CuSF3QpabBfwaJIpgPfWh5SU8ES2tfW40LHQ6gsm9PLZCCJd2RWBsAg-ynqMVmNvHBgTO9S5FFBEcILkSKcC_MyVjNUK-8qfhKnQQPXgamiQj-imh4f5H-01jn0qSbiIaNBBbWs8WaiEDX1JaFE1ANmzavVGRxShPILHL_US7TCQ871Vr89ga7Y0v9ivrNvS2rdS5oF4sLUvcuVq9FhTVrjZpfmMNrEcehBwAJB_oy0hwGoit3I9QwJBE7yTcTRuabRpUOY8NGnuBwzl_YpEii8y3zKUv_S56bGdV6hXrIYnlrC_lQUZISI14iKUD-Dc7DUmh-vDl7MpfMvKQUktHpIeGdJMdxqweC1QdHFvhFbZkfRgjDhnNc0jNtP6Mr34zH5R5LQ8XbdLc6hlCebmo1Cv6kdjNRaLtdI6Mq1Y9Gq0gPMRVyTIPtovc7LsBlfXwcpxl21GixCJ_-bI6UaPK3TmPi0p_29vNanWx2E5_SGwELeYDW3egDxwPLtz4Smx_kFWcH6inYTsQbi68YonwAEahac1GjyjGvrjYKsoS680WKuzZS0Mutx0IkRyRloqlBvNHBK06L-Wx8ojMF4axEl43YmtkRZeyfVWrwEc9a9vcONhKAJbgOACubfzVac9cjNyYppXgW-XK5SWGo9lcjkVg5r63SM4pv1skycGMDMD2AbX8kTiFoukUeCElgDzeLhXKgJGiMwyYHrbKUQRUDVrgVJ81WasDodKiQH-yZ_-PiXCHPergRl5I31036jtOI1ylLDUgKg6ut2knm_9eRe_tC2EEhxXtT7ZaLatnvaE4b2G0o_Q-izpmOx92rL3gkpo33T97hPc7J1sZW7kwt9HXHyZBXX5xZ6pdvi2b3fmxnjLYK0Ro&vjfrom=vjs&astse=d6aae2e110970c59&assa=588",
+ "tier": {
+ "matchedPreferences": {
+ "longMatchedPreferences": [],
+ "stringMatchedPreferences": []
+ },
+ "type": "DEFAULT"
+ },
+ "title": "Software Developer",
+ "translatedAttributes": [],
+ "translatedCmiJobTags": [],
+ "truncatedCompany": "TherapyNotes.com",
+ "urgentlyHiring": false,
+ "viewJobLink": "/viewjob?jk=7b993d015f4b257a&from=vjs&tk=1jtgbqmn921k6001&viewtype=embedded&advn=9533474731693465&adid=464623129&ad=-6NYlbfkN0AfEq5j7saNRG5aU_mvVTuWOB9eeCeHIli2OtKfZJ_lU68CQK3WO6fr1U8P9BG_8N1lyYEY1nzdWnmTq7hlQAqMT4EZoUMRYAQrNEOj5nb4ZE_F_ZCNcUyWwXqUnsb3IFHbnllXq0f5pYhflUexN3LD6mf9j8U6fPKOFmTQuul154CxQeo8aExEApf08b2iFfAx7Eci6fsR4i5wlRjvKA5EumK1U1Eauxzua2oUUaLKdAW9137H0CYgdcoAMF84PbbBCj5eW5R0p7Tfgi9NmbhJFRaEZv-u8JEg9U1zcdmaIh6RuPDU7bjQIuak5MO3DwaEJlHXEg4v7-XpR0uTAehVEkbNEFQdl0EwMnI32HxxwGJ_r7Xy7viI3kh6lEdrpZeF1ca18i9FlF_nce_H7BdzO2qmsM11dLrzIwGnipd9-NCmqh-jZJbJLooc2Yl3D8379SRHWpbfMz_a9aa1x_miN7s9YFssX_W8wrIqe3eHIaV-pRMzXMJYIxrcyPztopcC5udht92l9RFkJ8GjCK-zMHwiJRuCIqjML2cMsQblSxIpv5pb9M88dlUMlDd6dMpQBDYRrFe1PYyRu_QTWc_ggES6VZu7PJ7BcMPSFZ7cSuzuPT49lMRw7lYs3P1aakqoiFThrgWJAWATgalPtzZy754uEQG6ez5Gh-KW-nvDhDDfUS-fhvIMcDFftBf6gyU6_wEuH9GI7sOTR8b7jnHS285ryLd212LhBy0GJjliQdMxRmWK0qDqFr8z9q7Ufi9AHPafSIWTFw%3D%3D&xkcb=SoBj6_M3hwflOvyN8p0LbzkdCdPP&continueUrl=%2Fjobs%3Fq%3Dsoftware%2Bengineer%26l%3DRemote",
+ "vjFeaturedEmployerCandidate": false
+ },
+ {
+ "adBlob": "-6NYlbfkN0AfEq5j7saNRG5aU_mvVTuWOB9eeCeHIli2OtKfZJ_lU68CQK3WO6fr1U8P9BG_8N2MhaGUtj2kz1cGhiRJCPZR4-FTtJq5aUy42k66aUDfG-jeElTJaJF_uhS-sSFhT8XjUynnZ9RdOuIClMgX3_QeGQ1eZ8ZugnnoExqs4lHbEtd3kidPweUJRF0MyFycMxMCn9PPBMAr2RWtUUo1uwl1XqX3eSw72oEa1W-RXDX2rtTZXHOprn3W7VUJ7KBfEuz9dn-7yLd2EqAKhhT-rHN9Lm1ES3UePr384UySNQNOC05nSAXRD7sWGEFSLN7BMYsDwZMQA39fRPGioSyhsoNKnGbkEiEgeUpRce_grNxxR7lU_oVHb-QemsQv_jZWwwZ9Q2j74LtVfpNdO_U1is58Hju2-BHih_ov1gUxcwRJGEIQcmQGtqsaarwGZalEQeCafsMzlN1QpmPGpH55FsGE3d_71A39-xRsCn0gFkouk_KEV0G8g_DoU5G9ZMEGdSbPDpLHYwszFA6QFKKjcTqzcy7hntV6BtA5gVnr_pQWNY3XNhrT59XGBm34NnzOfGGT8kCDoyd8juFITsfyMFHEMDkIrek0JfU-1Y-FPzmaLCpVNxPkRC3e8xXPqqzzcXshv5_ZRxpTNgBuUrd_EdhZtv-yKLdC0FtfGSSl8rm9Aqexw0CS7Wivy6DdNTGpHNsOybkoisYqEDeYaDN_zuSYmznXafHCwT-FgULG5_GSI2d9kU__07ht6bD0RzHr-Jk=",
+ "adId": "464623129",
+ "advn": "9533474731693465",
+ "appliedOrGreater": false,
+ "applyCount": -1,
+ "assistedApply": false,
+ "autoSourcerJob": false,
+ "bidPosition": 4,
+ "blobKey": "SoDX6_M3hwflOvyN8p0KbzkdCdPP",
+ "company": "TherapyNotes.com",
+ "companyBrandingAttributes": {
+ "brandingReasons": [
+ "PAID_BRANDING"
+ ],
+ "brandingReasonsAsString": [
+ "PAID_BRANDING"
+ ],
+ "headerImageUrl": "https://d2q79iu7y748jz.cloudfront.net/s/_headerimage/1960x400/9d05368a0956c3266ea4291e95d34331",
+ "logoUrl": "https://d2q79iu7y748jz.cloudfront.net/s/_squarelogo/256x256/eac29e66f882990ae917c959915eb291",
+ "paidBrandingDealIds": [
+ "58664",
+ "95349"
+ ],
+ "showJobBranding": true,
+ "shownForBrandedJobPackage": false
+ },
+ "companyIdEncrypted": "d10e9cc0e75a9595",
+ "companyOverviewLink": "/cmp/Therapynotes",
+ "companyOverviewLinkCampaignId": "serp-linkcompanyname-content",
+ "companyRating": 3.8,
+ "companyReviewCount": 30,
+ "companyReviewLink": "/cmp/Therapynotes/reviews",
+ "companyReviewLinkCampaignId": "cmplinktst2",
+ "country": "US",
+ "createDate": 1782827792000,
+ "d2iEnabled": false,
+ "displayTitle": "Senior Software Developer (Agentic Development)",
+ "dradisJob": false,
+ "employerAssistEnabled": false,
+ "employerResponseTime": 1,
+ "employerResponsive": true,
+ "encryptedFccompanyId": "c5ef9b9375a8bbf1",
+ "encryptedResultData": "VwIPTVJ1cTn5AN7Q-tSqGRXGNe2wB2UYx73qSczFnGU",
+ "enhancedAttributesModel": {},
+ "enticers": [],
+ "expired": false,
+ "extractTrackingUrls": "",
+ "extractedSalary": {
+ "max": 135000,
+ "min": 110000,
+ "type": "YEARLY"
+ },
+ "fccompanyId": -1,
+ "featuredCompanyAttributes": {},
+ "featuredEmployer": false,
+ "featuredEmployerCandidate": false,
+ "feedId": 822778,
+ "formattedLocation": "Philadelphia, PA",
+ "formattedRelativeTime": "13 days ago",
+ "gatedVjp": false,
+ "hideMetaData": false,
+ "highVolumeHiringModel": {
+ "highVolumeHiring": false
+ },
+ "hiringEventJob": false,
+ "homepageJobFeedSectionId": "0",
+ "indeedApplyEnabled": true,
+ "indeedApplyFinishAppUrlEnabled": false,
+ "indeedApplyResumeType": "required",
+ "indeedApplyable": true,
+ "isJobVisited": false,
+ "isMobileThirdPartyApplyable": false,
+ "isNoResumeJob": false,
+ "isSubsidiaryJob": false,
+ "isTopRatedEmployer": false,
+ "jobCardRequirementsModel": {
+ "additionalRequirementsCount": 0,
+ "jobOnlyRequirements": [],
+ "jobTagRequirements": [],
+ "requirementsHeaderShown": false,
+ "screenerQuestionRequirements": []
+ },
+ "jobFlairPackageEnabled": false,
+ "jobLocationCity": "Philadelphia",
+ "jobLocationState": "PA",
+ "jobSeekerMatchSummaryModel": {
+ "sortedEntityDisplayText": [],
+ "sortedMatchingEntityDisplayText": [],
+ "sortedMisMatchingEntityDisplayText": [
+ ".NET Core",
+ "AI models",
+ "AI platforms (beyond public GPTs)",
+ "AI use case identification",
+ "AI-driven automation",
+ "API integrations",
+ "Agile software development",
+ "Automation software",
+ "Bachelor's degree",
+ "Collaboration with product development teams",
+ "Communication skills",
+ "Computer Science",
+ "Computer science",
+ "Continuous improvement for developers",
+ "Cross-functional communication",
+ "Design (software development lifecycle)",
+ "Developer tools",
+ "Employee onboarding",
+ "Engineering process optimization",
+ "Entity Framework",
+ "Full-stack development",
+ "Generative AI",
+ "HTML",
+ "IT quality control",
+ "JavaScript frameworks",
+ "Mentoring",
+ "PostgreSQL",
+ "Process design",
+ "Product management",
+ "Prompt engineering",
+ "Providing code feedback",
+ "Quality assurance experience within IT",
+ "Quality control",
+ "Release management",
+ "SASS/LESS",
+ "SQL databases",
+ "SaaS",
+ "Scalability",
+ "Scalable systems",
+ "Software Engineering",
+ "Software engineering",
+ "Software testing",
+ "System design",
+ "System design for system development",
+ "Team training",
+ "Technology security practices",
+ "Tooling",
+ "TypeScript"
+ ],
+ "taxoEntityMatchesNegative": [
+ {
+ "displayText": "SASS/LESS",
+ "id": "",
+ "rawName": "SASS/LESS",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "FE6SC"
+ },
+ {
+ "displayText": "Software testing",
+ "id": "",
+ "rawName": "Software testing",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "PZP9P"
+ },
+ {
+ "displayText": "Computer science",
+ "id": "",
+ "rawName": "Computer science",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "4E4WW"
+ },
+ {
+ "displayText": "JavaScript frameworks",
+ "id": "",
+ "rawName": "JavaScript frameworks",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "EUPT5"
+ },
+ {
+ "displayText": "HTML",
+ "id": "",
+ "rawName": "HTML",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "Y7U37"
+ },
+ {
+ "displayText": "System design",
+ "id": "",
+ "rawName": "System design",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "C5C2F"
+ },
+ {
+ "displayText": "Mentoring",
+ "id": "",
+ "rawName": "Mentoring",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "MGSEB"
+ },
+ {
+ "displayText": "Cross-functional communication",
+ "id": "",
+ "rawName": "Cross-functional communication",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "Y8657"
+ },
+ {
+ "displayText": "Process design",
+ "id": "",
+ "rawName": "Process design",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "C4MUU"
+ },
+ {
+ "displayText": "AI use case identification",
+ "id": "",
+ "rawName": "AI use case identification",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "4KV6M"
+ },
+ {
+ "displayText": "Design (software development lifecycle)",
+ "id": "",
+ "rawName": "Design (software development lifecycle)",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "XA6CW"
+ },
+ {
+ "displayText": "Scalable systems",
+ "id": "",
+ "rawName": "Scalable systems",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "DP9CS"
+ },
+ {
+ "displayText": "Quality control",
+ "id": "",
+ "rawName": "Quality control",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "K9VX6"
+ },
+ {
+ "displayText": "AI models",
+ "id": "",
+ "rawName": "AI models",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "22JPW"
+ },
+ {
+ "displayText": "SQL databases",
+ "id": "",
+ "rawName": "SQL databases",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "7G8UN"
+ },
+ {
+ "displayText": "Continuous improvement for developers",
+ "id": "",
+ "rawName": "Continuous improvement for developers",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "TZQ7R"
+ },
+ {
+ "displayText": "Software Engineering",
+ "id": "",
+ "rawName": "Software Engineering",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "7F6H9"
+ },
+ {
+ "displayText": "Quality assurance experience within IT",
+ "id": "",
+ "rawName": "Quality assurance experience within IT",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "EC9V5"
+ },
+ {
+ "displayText": "Entity Framework",
+ "id": "",
+ "rawName": "Entity Framework",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "X5HXD"
+ },
+ {
+ "displayText": "Computer Science",
+ "id": "",
+ "rawName": "Computer Science",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "6XNCP"
+ },
+ {
+ "displayText": "Product management",
+ "id": "",
+ "rawName": "Product management",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "JT9DE"
+ },
+ {
+ "displayText": "Providing code feedback",
+ "id": "",
+ "rawName": "Providing code feedback",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "Z4F5N"
+ },
+ {
+ "displayText": "Engineering process optimization",
+ "id": "",
+ "rawName": "Engineering process optimization",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "CCQ9J"
+ },
+ {
+ "displayText": "Collaboration with product development teams",
+ "id": "",
+ "rawName": "Collaboration with product development teams",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "GEYN2"
+ },
+ {
+ "displayText": "PostgreSQL",
+ "id": "",
+ "rawName": "PostgreSQL",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "JCKB4"
+ },
+ {
+ "displayText": "AI-driven automation",
+ "id": "",
+ "rawName": "AI-driven automation",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "TX6QF"
+ },
+ {
+ "displayText": "IT quality control",
+ "id": "",
+ "rawName": "IT quality control",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "AHSXR"
+ },
+ {
+ "displayText": "Release management",
+ "id": "",
+ "rawName": "Release management",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "J3CT2"
+ },
+ {
+ "displayText": "Prompt engineering",
+ "id": "",
+ "rawName": "Prompt engineering",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "EV2FP"
+ },
+ {
+ "displayText": "Tooling",
+ "id": "",
+ "rawName": "Tooling",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "93YM8"
+ },
+ {
+ "displayText": "AI platforms (beyond public GPTs)",
+ "id": "",
+ "rawName": "AI platforms (beyond public GPTs)",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "DP8RP"
+ },
+ {
+ "displayText": ".NET Core",
+ "id": "",
+ "rawName": ".NET Core",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "AGW49"
+ },
+ {
+ "displayText": "TypeScript",
+ "id": "",
+ "rawName": "TypeScript",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "WD7PP"
+ },
+ {
+ "displayText": "Developer tools",
+ "id": "",
+ "rawName": "Developer tools",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "WE46Y"
+ },
+ {
+ "displayText": "SaaS",
+ "id": "",
+ "rawName": "SaaS",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "UC5ZW"
+ },
+ {
+ "displayText": "Team training",
+ "id": "",
+ "rawName": "Team training",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "JSNTU"
+ },
+ {
+ "displayText": "Full-stack development",
+ "id": "",
+ "rawName": "Full-stack development",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "8PA9N"
+ },
+ {
+ "displayText": "Scalability",
+ "id": "",
+ "rawName": "Scalability",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "PYTWK"
+ },
+ {
+ "displayText": "Automation software",
+ "id": "",
+ "rawName": "Automation software",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "ANHQ8"
+ },
+ {
+ "displayText": "Agile software development",
+ "id": "",
+ "rawName": "Agile software development",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "QWGDE"
+ },
+ {
+ "displayText": "System design for system development",
+ "id": "",
+ "rawName": "System design for system development",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "FNWKU"
+ },
+ {
+ "displayText": "Generative AI",
+ "id": "",
+ "rawName": "Generative AI",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "Y6B2U"
+ },
+ {
+ "displayText": "Bachelor's degree",
+ "id": "",
+ "rawName": "Bachelor's degree",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "HFDVW"
+ },
+ {
+ "displayText": "API integrations",
+ "id": "",
+ "rawName": "API integrations",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "TXVT6"
+ },
+ {
+ "displayText": "Software engineering",
+ "id": "",
+ "rawName": "Software engineering",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "4R4AM"
+ },
+ {
+ "displayText": "Communication skills",
+ "id": "",
+ "rawName": "Communication skills",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "WSBNK"
+ },
+ {
+ "displayText": "Technology security practices",
+ "id": "",
+ "rawName": "Technology security practices",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "EKUZN"
+ },
+ {
+ "displayText": "Employee onboarding",
+ "id": "",
+ "rawName": "Employee onboarding",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "3ERWX"
+ }
+ ],
+ "taxoEntityMatchesPositive": [],
+ "trafficLight": "yellow"
+ },
+ "jobTypes": [],
+ "jobkey": "1964995584dc3547",
+ "jsiEnabled": false,
+ "link": "/pagead/clk?mo=r&ad=-6NYlbfkN0AfEq5j7saNRG5aU_mvVTuWOB9eeCeHIli2OtKfZJ_lU68CQK3WO6fr1U8P9BG_8N2MhaGUtj2kz1cGhiRJCPZR4-FTtJq5aUy42k66aUDfG-jeElTJaJF_uhS-sSFhT8XjUynnZ9RdOuIClMgX3_QeGQ1eZ8ZugnnoExqs4lHbEtd3kidPweUJRF0MyFycMxMCn9PPBMAr2RWtUUo1uwl1XqX3eSw72oEa1W-RXDX2rtTZXHOprn3W7VUJ7KBfEuz9dn-7yLd2EqAKhhT-rHN9Lm1ES3UePr384UySNQNOC05nSAXRD7sWGEFSLN7BMYsDwZMQA39fRPGioSyhsoNKnGbkEiEgeUpRce_grNxxR7lU_oVHb-QemsQv_jZWwwZ9Q2j74LtVfpNdO_U1is58Hju2-BHih_ov1gUxcwRJGEIQcmQGtqsaarwGZalEQeCafsMzlN1QpmPGpH55FsGE3d_71A39-xRsCn0gFkouk_KEV0G8g_DoU5G9ZMEGdSbPDpLHYwszFA6QFKKjcTqzcy7hntV6BtA5gVnr_pQWNY3XNhrT59XGBm34NnzOfGGT8kCDoyd8juFITsfyMFHEMDkIrek0JfU-1Y-FPzmaLCpVNxPkRC3e8xXPqqzzcXshv5_ZRxpTNgBuUrd_EdhZtv-yKLdC0FtfGSSl8rm9Aqexw0CS7Wivy6DdNTGpHNsOybkoisYqEDeYaDN_zuSYmznXafHCwT-FgULG5_GSI2d9kU__07ht6bD0RzHr-Jk=&xkcb=SoDX6_M3hwflOvyN8p0KbzkdCdPP&p=1&camk=C3EPSzFlQw-JWkl1H7Zuog==&vjs=3",
+ "locationCount": 1,
+ "minimumCount": 1,
+ "mobtk": "1jtgbqmn921k6001",
+ "moreLocUrl": "/jobs?q=software+engineer&nl=&l=Remote&radius=50&jtid=7ca63af8b5ec590b&jcid=d10e9cc0e75a9595&grp=tcl",
+ "mouseDownHandlerOption": {
+ "adId": 464623129,
+ "advn": "9533474731693465",
+ "extractTrackingUrls": [],
+ "from": "vjs",
+ "jobKey": "1964995584dc3547",
+ "link": "/pagead/clk?mo=r&ad=-6NYlbfkN0AfEq5j7saNRG5aU_mvVTuWOB9eeCeHIli2OtKfZJ_lU68CQK3WO6fr1U8P9BG_8N2MhaGUtj2kz1cGhiRJCPZR4-FTtJq5aUy42k66aUDfG-jeElTJaJF_uhS-sSFhT8XjUynnZ9RdOuIClMgX3_QeGQ1eZ8ZugnnoExqs4lHbEtd3kidPweUJRF0MyFycMxMCn9PPBMAr2RWtUUo1uwl1XqX3eSw72oEa1W-RXDX2rtTZXHOprn3W7VUJ7KBfEuz9dn-7yLd2EqAKhhT-rHN9Lm1ES3UePr384UySNQNOC05nSAXRD7sWGEFSLN7BMYsDwZMQA39fRPGioSyhsoNKnGbkEiEgeUpRce_grNxxR7lU_oVHb-QemsQv_jZWwwZ9Q2j74LtVfpNdO_U1is58Hju2-BHih_ov1gUxcwRJGEIQcmQGtqsaarwGZalEQeCafsMzlN1QpmPGpH55FsGE3d_71A39-xRsCn0gFkouk_KEV0G8g_DoU5G9ZMEGdSbPDpLHYwszFA6QFKKjcTqzcy7hntV6BtA5gVnr_pQWNY3XNhrT59XGBm34NnzOfGGT8kCDoyd8juFITsfyMFHEMDkIrek0JfU-1Y-FPzmaLCpVNxPkRC3e8xXPqqzzcXshv5_ZRxpTNgBuUrd_EdhZtv-yKLdC0FtfGSSl8rm9Aqexw0CS7Wivy6DdNTGpHNsOybkoisYqEDeYaDN_zuSYmznXafHCwT-FgULG5_GSI2d9kU__07ht6bD0RzHr-Jk=&xkcb=SoDX6_M3hwflOvyN8p0KbzkdCdPP&p=1&camk=C3EPSzFlQw-JWkl1H7Zuog==&vjs=3",
+ "tk": "1jtgbqmn921k6001"
+ },
+ "newJob": false,
+ "normTitle": "Senior Software Engineer",
+ "numHires": 0,
+ "openInterviewsInterviewsOnTheSpot": false,
+ "openInterviewsJob": false,
+ "openInterviewsOffersOnTheSpot": false,
+ "openInterviewsPhoneJob": false,
+ "organicApplyStartCount": 55,
+ "overrideIndeedApplyText": true,
+ "packageTier": "NONE",
+ "preciseLocationModel": {
+ "obfuscateLocation": false,
+ "overrideJCMPreciseLocationModel": true
+ },
+ "pubDate": 1782795600000,
+ "rankedBenefits": {
+ "GENERIC": [
+ "YQ98H",
+ "RZAT2",
+ "FQJ2X",
+ "CFRGS",
+ "4C2ZW"
+ ],
+ "OCCUPATION_TFIDF": [
+ "4C2ZW",
+ "CFRGS",
+ "FQJ2X",
+ "RZAT2",
+ "YQ98H"
+ ]
+ },
+ "rankingScoresModel": {
+ "bid": 90638,
+ "bidPosition": 4,
+ "eApply": 0.011701469,
+ "eAttainability": 0,
+ "eQualified": 0
+ },
+ "recommendationReasonModel": {
+ "reason": null
+ },
+ "redirectToThirdPartySite": false,
+ "remoteLocation": false,
+ "remoteWorkModel": {
+ "inlineText": true,
+ "text": "Remote",
+ "type": "REMOTE_ALWAYS"
+ },
+ "resultBeforeExpansion": false,
+ "resumeMatch": false,
+ "salarySnippet": {
+ "currency": "USD",
+ "salaryTextFormatted": false,
+ "source": "EXTRACTION",
+ "text": "$110,000 - $135,000 a year"
+ },
+ "saved": false,
+ "savedApplication": false,
+ "screenerQuestionsURL": "iqp://INDEXEDJOB/10183863070?country=US",
+ "searchUID": "1jtgbqmn921k6001",
+ "showCommutePromo": false,
+ "showEarlyApply": false,
+ "showJobType": false,
+ "showRelativeDate": true,
+ "showSponsoredLabel": false,
+ "showStrongerAppliedLabel": false,
+ "smartFillEnabled": false,
+ "smbD2iEnabled": false,
+ "snippet": "\n Ability to design reliable, maintainable, and scalable software architectures. \n You engineer tools, processes and workflows that meaningfully reduce friction in\u2026 \n ",
+ "sourceId": 3368918,
+ "sponsored": true,
+ "taxoAttributes": [
+ "Profit sharing"
+ ],
+ "taxoAttributesDisplayLimit": 3,
+ "taxoLogAttributes": [
+ "4C2ZW"
+ ],
+ "taxonomyAttributes": [
+ {
+ "attributes": [
+ {
+ "label": "Full-time",
+ "suid": "CF3CP"
+ }
+ ],
+ "label": "job-types"
+ },
+ {
+ "attributes": [],
+ "label": "shifts"
+ },
+ {
+ "attributes": [
+ {
+ "label": "Remote",
+ "suid": "DSQF7"
+ }
+ ],
+ "label": "remote"
+ },
+ {
+ "attributes": [
+ {
+ "label": "Dental insurance",
+ "suid": "FQJ2X"
+ },
+ {
+ "label": "Disability insurance",
+ "suid": "CFRGS"
+ },
+ {
+ "label": "Vision insurance",
+ "suid": "RZAT2"
+ },
+ {
+ "label": "Retirement plan",
+ "suid": "YQ98H"
+ },
+ {
+ "label": "Profit sharing",
+ "suid": "4C2ZW"
+ }
+ ],
+ "label": "benefits"
+ },
+ {
+ "attributes": [
+ {
+ "label": "Full-time",
+ "suid": "CF3CP"
+ }
+ ],
+ "label": "job-types-cc"
+ },
+ {
+ "attributes": [],
+ "label": "schedules"
+ }
+ ],
+ "thirdPartyApplyUrl": "http://www.indeed.com//applystart?jk=1964995584dc3547&from=jobsearch&mvj=0&jobsearchTk=1jtgbqmn921k6001&spon=1&adid=464623129&ad=-6NYlbfkN0AfEq5j7saNRG5aU_mvVTuWOB9eeCeHIli2OtKfZJ_lU68CQK3WO6fr1U8P9BG_8N2MhaGUtj2kz1cGhiRJCPZR4-FTtJq5aUy42k66aUDfG-jeElTJaJF_uhS-sSFhT8XjUynnZ9RdOuIClMgX3_QeGQ1eZ8ZugnnoExqs4lHbEtd3kidPweUJRF0MyFycMxMCn9PPBMAr2RWtUUo1uwl1XqX3eSw72oEa1W-RXDX2rtTZXHOprn3W7VUJ7KBfEuz9dn-7yLd2EqAKhhT-rHN9Lm1ES3UePr384UySNQNOC05nSAXRD7sWGEFSLN7BMYsDwZMQA39fRPGioSyhsoNKnGbkEiEgeUpRce_grNxxR7lU_oVHb-QemsQv_jZWwwZ9Q2j74LtVfpNdO_U1is58Hju2-BHih_ov1gUxcwRJGEIQcmQGtqsaarwGZalEQeCafsMzlN1QpmPGpH55FsGE3d_71A39-xRsCn0gFkouk_KEV0G8g_DoU5G9ZMEGdSbPDpLHYwszFA6QFKKjcTqzcy7hntV6BtA5gVnr_pQWNY3XNhrT59XGBm34NnzOfGGT8kCDoyd8juFITsfyMFHEMDkIrek0JfU-1Y-FPzmaLCpVNxPkRC3e8xXPqqzzcXshv5_ZRxpTNgBuUrd_EdhZtv-yKLdC0FtfGSSl8rm9Aqexw0CS7Wivy6DdNTGpHNsOybkoisYqEDeYaDN_zuSYmznXafHCwT-FgULG5_GSI2d9kU__07ht6bD0RzHr-Jk%3D&xkcb=SoDX6_M3hwflOvyN8p0KbzkdCdPP&sjdu=Xbbwb0dbVlAPs0TIG7JumEX25uTOF1Oj2WnXk16AywHWA2PUcrZQMHMXHdXk2eTvbAixkyUF3j-OOmJlYeYtIjeDAvq7rEASgcC0zRMWutD3E9nQwSeMuCzdft7Qzt3FrQrTab10PIp0rWyA5dpjRE_z_G8OF7DNUc4gvnFFI-gpZwJRNYrOmZ5GZc_T8c2uHtcA7yEHKanT-ZgKiAexbe5qjDvO2kXqUnLLpW7YdwVJyc9eBIFg_vfY1-syU0GBwcsCuyrZU8nv0Xq8G93mw-7llCMyNJkF-WtwsM9Ue6aeLchHe0XGbSYl1Qcgf0sY-JQpXW7ju_Bk-MDoBCtis20YCc1bVmoQthnzEBWFp_9TYbQKJLr11DQp75s5Vdmsf-d5IQTi2PMp0iW7xymdYEQ3LDGdq2iavfaMS8A8OQ6rOQXa9jJFvJk9Ta5XeU_mpy5fSFg1oxkr0xp2QA2Utj3433zpiWFKDsr3Df6bWNvXjJoRzwIWKUpx2esmkKj7JybAVwMqmDoSkpfVABPfjuxY1hcCETtdEgmRbv6rBt0LOZX4H3srbX-kBOf6bWogp0PCe91mjDN2aw61THWKieSLyEzPUCYuoYr2onoda8SvUcksZX8zNPm_6b_s-yzzK9gO8ayTD6J-4jLltcKnpVPDYlxxCOFzCE00ROrg3Fdsv5xMIDVLwW7-zd6Rj8UrSVI77Ngt6Xuv4QptvZuF1trAUcMXIh9eq5jBrwb3_p3iAChwWpQm2LoaxnbeXmY4lWLNx-GuVCMQC9PPCdO2IZVUI2AHBR-WjvuESBVvRXlAXYLKObNGVDkrcUxV8nvoCcVndtP9TF5ZORGRAdWScvy63d0aKgnOkxJ3fSSHwTlRDOEY_tIYg31V8b1f21PG3DeLB17KWd6yFJNAPxgz99fRLl22swix5fq0lRJsIkN8K5eNtzDQ8b2M5QNT_-vr7ydAs3NiWqZbkns80Rhva-g4ppYeirl4F-oE27jkbsyR0mT19gIbooC-0xxw5rhbJVd3xcU-Qns5G3Nr8utN_PCj8MNuo8AomjLddrI-OA_DFCxJ9dMx4OQltXVjCk4TJI5pSLZxWVsDOsIirhwPN4O6LTFptkPn8PQP0M-MqfFUyGuzU73dybEBIAltr8aORGKf6dZx5anCwoZfBAU3zQ&vjfrom=vjs&astse=da7fff3285780cc4&assa=589",
+ "tier": {
+ "matchedPreferences": {
+ "longMatchedPreferences": [],
+ "stringMatchedPreferences": []
+ },
+ "type": "DEFAULT"
+ },
+ "title": "Senior Software Developer (Agentic Development)",
+ "translatedAttributes": [],
+ "translatedCmiJobTags": [],
+ "truncatedCompany": "TherapyNotes.com",
+ "urgentlyHiring": false,
+ "viewJobLink": "/viewjob?jk=1964995584dc3547&from=vjs&tk=1jtgbqmn921k6001&viewtype=embedded&advn=9533474731693465&adid=464623129&ad=-6NYlbfkN0AfEq5j7saNRG5aU_mvVTuWOB9eeCeHIli2OtKfZJ_lU68CQK3WO6fr1U8P9BG_8N2MhaGUtj2kz1cGhiRJCPZR4-FTtJq5aUy42k66aUDfG-jeElTJaJF_uhS-sSFhT8XjUynnZ9RdOuIClMgX3_QeGQ1eZ8ZugnnoExqs4lHbEtd3kidPweUJRF0MyFycMxMCn9PPBMAr2RWtUUo1uwl1XqX3eSw72oEa1W-RXDX2rtTZXHOprn3W7VUJ7KBfEuz9dn-7yLd2EqAKhhT-rHN9Lm1ES3UePr384UySNQNOC05nSAXRD7sWGEFSLN7BMYsDwZMQA39fRPGioSyhsoNKnGbkEiEgeUpRce_grNxxR7lU_oVHb-QemsQv_jZWwwZ9Q2j74LtVfpNdO_U1is58Hju2-BHih_ov1gUxcwRJGEIQcmQGtqsaarwGZalEQeCafsMzlN1QpmPGpH55FsGE3d_71A39-xRsCn0gFkouk_KEV0G8g_DoU5G9ZMEGdSbPDpLHYwszFA6QFKKjcTqzcy7hntV6BtA5gVnr_pQWNY3XNhrT59XGBm34NnzOfGGT8kCDoyd8juFITsfyMFHEMDkIrek0JfU-1Y-FPzmaLCpVNxPkRC3e8xXPqqzzcXshv5_ZRxpTNgBuUrd_EdhZtv-yKLdC0FtfGSSl8rm9Aqexw0CS7Wivy6DdNTGpHNsOybkoisYqEDeYaDN_zuSYmznXafHCwT-FgULG5_GSI2d9kU__07ht6bD0RzHr-Jk%3D&xkcb=SoDX6_M3hwflOvyN8p0KbzkdCdPP&continueUrl=%2Fjobs%3Fq%3Dsoftware%2Bengineer%26l%3DRemote",
+ "vjFeaturedEmployerCandidate": false
+ },
+ {
+ "adBlob": "-6NYlbfkN0CnM4TERr6XuVBW3VTRACvDXDx9S3Pbn6a0SwhkmoLTiLCFUpITpg4QVB7SR39MoP0BLR841cgbHYPJopI9fiQa67MfGt-TS3gF6m2xSij4aAarTk90jQV5vKgeHuaG-dJ4P0bDI4Zj_HA51wzDk11uApXr5YRiDtyBwiQNLf5d9Lyctn13orSbPQeRCVH0zqauP5hiYL4ZkfdYeGe3I1Wbq855uSspMHp95AmMxgY4r8gBmfjoH2ZckgGERlYYLnGOotBKSI7i1CVIHtUDpAaF8MlAFpdDsF7JJ3NyS5QmqxHMQDQvH-MTyCDpC3bADAnfw6-khs-18fT3MQkZxaiHCnFCeCieu5HX8FLJW0Vbld4azqHzvV6iSucVFTJi1AKqwERerK9DWIfC0zose7qxVHyZkCqyCO7MtebmgcFgz8G-TMEKd3KEBdjQHW4g989DJ5vUEw0r_96qW6vxxph2br8GDqh0gRdNttZbE8iRAL2dJQGEiGkx-SEOJbw4SoVYUwpjBejBIJ-rdG4cyC5ppwrlxPXTlnAcqq9Ie16k5dyg-_YEGJgAXpTkGiS_jUefF4WFy9TLA5mJmWV1UGK991Seolocw55nIqu8uewnGZrKlzn_URF3fqCCkPEu048MFp3V5B32llPFdw6ecDZM4ixH_Y6Ex2ja_KQrD-VhNLIsQT0l0xiRsuD4NxCSVzIO3oh4AR-qxkF088JN5s0b7vYNrPpc20UUPEMmP9UTsO9TnPKrxhEPZX0qoIvEdXQwWA__4p652c_mTlKFK9F-VOq0Af-rMFWJFT2H9Cstjh4J7RyLp70DgUD5IJYLysP3eEEvknyDAOnB3IbnpMF4vePgU0vTSD-QHvvd-GxNquZ1d-hGjAfcdG70mwq736OitfXAGrZBSH7tm4yOCxSllF5t1z0TIE-LuM1UKKvYFt1H8B8TI-mhgtXwx6FhLAytzRKYOQlO5A==",
+ "adId": "461480873",
+ "advn": "7831672186689475",
+ "appliedOrGreater": false,
+ "applyCount": -1,
+ "assistedApply": false,
+ "autoSourcerJob": false,
+ "bidPosition": 3,
+ "blobKey": "SoBK6_M3hwflOvyN8p0JbzkdCdPP",
+ "company": "DataAnnotation",
+ "companyBrandingAttributes": {
+ "brandingReasons": [
+ "PAID_BRANDING"
+ ],
+ "brandingReasonsAsString": [
+ "PAID_BRANDING"
+ ],
+ "headerImageUrl": "https://d2q79iu7y748jz.cloudfront.net/s/_headerimage/1960x400/e42fbb2b9f70343758932438638f973c",
+ "logoUrl": "https://d2q79iu7y748jz.cloudfront.net/s/_squarelogo/256x256/cc8dc489da5211695773d9f9020812b0",
+ "paidBrandingDealIds": [
+ "88129"
+ ],
+ "showJobBranding": true,
+ "shownForBrandedJobPackage": false
+ },
+ "companyIdEncrypted": "342de9268ba1a972",
+ "companyOverviewLink": "/cmp/Dataannotation",
+ "companyOverviewLinkCampaignId": "serp-linkcompanyname-content",
+ "companyRating": 4.1,
+ "companyReviewCount": 1661,
+ "companyReviewLink": "/cmp/Dataannotation/reviews",
+ "companyReviewLinkCampaignId": "cmplinktst2",
+ "country": "US",
+ "createDate": 1778281962938,
+ "d2iEnabled": false,
+ "displayTitle": "Java Developer - AI Trainer",
+ "dradisJob": false,
+ "employerAssistEnabled": false,
+ "employerResponsive": false,
+ "encryptedFccompanyId": "e3735f59dc6a6354",
+ "encryptedResultData": "VwIPTVJ1cTn5AN7Q-tSqGRXGNe2wB2UYx73qSczFnGU",
+ "enhancedAttributesModel": {},
+ "enticers": [],
+ "expired": false,
+ "extractTrackingUrls": "",
+ "extractedSalary": {
+ "max": 100,
+ "min": 0,
+ "type": "HOURLY"
+ },
+ "fccompanyId": -1,
+ "featuredCompanyAttributes": {},
+ "featuredEmployer": false,
+ "featuredEmployerCandidate": false,
+ "feedId": 915432,
+ "formattedLocation": "Remote",
+ "formattedRelativeTime": "30+ days ago",
+ "gatedVjp": false,
+ "hideMetaData": false,
+ "highVolumeHiringModel": {
+ "highVolumeHiring": false
+ },
+ "hiringEventJob": false,
+ "homepageJobFeedSectionId": "0",
+ "indeedApplyEnabled": true,
+ "indeedApplyFinishAppUrlEnabled": true,
+ "indeedApplyResumeType": "required",
+ "indeedApplyable": true,
+ "isJobVisited": false,
+ "isMobileThirdPartyApplyable": false,
+ "isNoResumeJob": false,
+ "isSubsidiaryJob": false,
+ "isTopRatedEmployer": false,
+ "jobCardRequirementsModel": {
+ "additionalRequirementsCount": 0,
+ "jobOnlyRequirements": [],
+ "jobTagRequirements": [],
+ "requirementsHeaderShown": false,
+ "screenerQuestionRequirements": []
+ },
+ "jobFlairPackageEnabled": false,
+ "jobLocationCity": "Remote",
+ "jobSeekerMatchSummaryModel": {
+ "sortedEntityDisplayText": [],
+ "sortedMatchingEntityDisplayText": [],
+ "sortedMisMatchingEntityDisplayText": [
+ "AI models",
+ "Bachelor's degree",
+ "C#",
+ "Data analytics",
+ "Data visualization",
+ "English",
+ "Go",
+ "Grammar Experience",
+ "JavaScript frameworks",
+ "Kotlin",
+ "Model training",
+ "Providing code feedback",
+ "Software coding",
+ "Software design",
+ "Software engineering",
+ "Swift",
+ "TypeScript",
+ "Writing skills"
+ ],
+ "taxoEntityMatchesNegative": [
+ {
+ "displayText": "Software design",
+ "id": "",
+ "rawName": "Software design",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "7DZKQ"
+ },
+ {
+ "displayText": "Go",
+ "id": "",
+ "rawName": "Go",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "6ETQT"
+ },
+ {
+ "displayText": "JavaScript frameworks",
+ "id": "",
+ "rawName": "JavaScript frameworks",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "EUPT5"
+ },
+ {
+ "displayText": "English",
+ "id": "",
+ "rawName": "English",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "D866K"
+ },
+ {
+ "displayText": "TypeScript",
+ "id": "",
+ "rawName": "TypeScript",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "WD7PP"
+ },
+ {
+ "displayText": "Model training",
+ "id": "",
+ "rawName": "Model training",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "QZ89D"
+ },
+ {
+ "displayText": "AI models",
+ "id": "",
+ "rawName": "AI models",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "22JPW"
+ },
+ {
+ "displayText": "Kotlin",
+ "id": "",
+ "rawName": "Kotlin",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "4KE5P"
+ },
+ {
+ "displayText": "Data visualization",
+ "id": "",
+ "rawName": "Data visualization",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "TKG4S"
+ },
+ {
+ "displayText": "Bachelor's degree",
+ "id": "",
+ "rawName": "Bachelor's degree",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "HFDVW"
+ },
+ {
+ "displayText": "Software engineering",
+ "id": "",
+ "rawName": "Software engineering",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "4R4AM"
+ },
+ {
+ "displayText": "Data analytics",
+ "id": "",
+ "rawName": "Data analytics",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "NGW4T"
+ },
+ {
+ "displayText": "Software coding",
+ "id": "",
+ "rawName": "Software coding",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "9MWQS"
+ },
+ {
+ "displayText": "Writing skills",
+ "id": "",
+ "rawName": "Writing skills",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "A7SFW"
+ },
+ {
+ "displayText": "Grammar Experience",
+ "id": "",
+ "rawName": "Grammar Experience",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "VTSX9"
+ },
+ {
+ "displayText": "Providing code feedback",
+ "id": "",
+ "rawName": "Providing code feedback",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "Z4F5N"
+ },
+ {
+ "displayText": "C#",
+ "id": "",
+ "rawName": "C#",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "AP3T4"
+ },
+ {
+ "displayText": "Swift",
+ "id": "",
+ "rawName": "Swift",
+ "source": "JOB",
+ "strictness": "",
+ "suid": "U5AXZ"
+ }
+ ],
+ "taxoEntityMatchesPositive": [],
+ "trafficLight": "yellow"
+ },
+ "jobTypes": [],
+ "jobkey": "40da1d788bab69b0",
+ "jsiEnabled": false,
+ "link": "/pagead/clk?mo=r&ad=-6NYlbfkN0CnM4TERr6XuVBW3VTRACvDXDx9S3Pbn6a0SwhkmoLTiLCFUpITpg4QVB7SR39MoP0BLR841cgbHYPJopI9fiQa67MfGt-TS3gF6m2xSij4aAarTk90jQV5vKgeHuaG-dJ4P0bDI4Zj_HA51wzDk11uApXr5YRiDtyBwiQNLf5d9Lyctn13orSbPQeRCVH0zqauP5hiYL4ZkfdYeGe3I1Wbq855uSspMHp95AmMxgY4r8gBmfjoH2ZckgGERlYYLnGOotBKSI7i1CVIHtUDpAaF8MlAFpdDsF7JJ3NyS5QmqxHMQDQvH-MTyCDpC3bADAnfw6-khs-18fT3MQkZxaiHCnFCeCieu5HX8FLJW0Vbld4azqHzvV6iSucVFTJi1AKqwERerK9DWIfC0zose7qxVHyZkCqyCO7MtebmgcFgz8G-TMEKd3KEBdjQHW4g989DJ5vUEw0r_96qW6vxxph2br8GDqh0gRdNttZbE8iRAL2dJQGEiGkx-SEOJbw4SoVYUwpjBejBIJ-rdG4cyC5ppwrlxPXTlnAcqq9Ie16k5dyg-_YEGJgAXpTkGiS_jUefF4WFy9TLA5mJmWV1UGK991Seolocw55nIqu8uewnGZrKlzn_URF3fqCCkPEu048MFp3V5B32llPFdw6ecDZM4ixH_Y6Ex2ja_KQrD-VhNLIsQT0l0xiRsuD4NxCSVzIO3oh4AR-qxkF088JN5s0b7vYNrPpc20UUPEMmP9UTsO9TnPKrxhEPZX0qoIvEdXQwWA__4p652c_mTlKFK9F-VOq0Af-rMFWJFT2H9Cstjh4J7RyLp70DgUD5IJYLysP3eEEvknyDAOnB3IbnpMF4vePgU0vTSD-QHvvd-GxNquZ1d-hGjAfcdG70mwq736OitfXAGrZBSH7tm4yOCxSllF5t1z0TIE-LuM1UKKvYFt1H8B8TI-mhgtXwx6FhLAytzRKYOQlO5A==&xkcb=SoBK6_M3hwflOvyN8p0JbzkdCdPP&p=2&camk=C3EPSzFlQw95L2GYPjH5nw==&vjs=3",
+ "locationCount": 1,
+ "minimumCount": 1,
+ "mobtk": "1jtgbqmn921k6001",
+ "moreLocUrl": "/jobs?q=software+engineer&nl=&l=Remote&radius=50&jtid=c1b76b0304ad72c6&jcid=342de9268ba1a972&grp=tcl",
+ "mouseDownHandlerOption": {
+ "adId": 461480873,
+ "advn": "7831672186689475",
+ "extractTrackingUrls": [],
+ "from": "vjs",
+ "jobKey": "40da1d788bab69b0",
+ "link": "/pagead/clk?mo=r&ad=-6NYlbfkN0CnM4TERr6XuVBW3VTRACvDXDx9S3Pbn6a0SwhkmoLTiLCFUpITpg4QVB7SR39MoP0BLR841cgbHYPJopI9fiQa67MfGt-TS3gF6m2xSij4aAarTk90jQV5vKgeHuaG-dJ4P0bDI4Zj_HA51wzDk11uApXr5YRiDtyBwiQNLf5d9Lyctn13orSbPQeRCVH0zqauP5hiYL4ZkfdYeGe3I1Wbq855uSspMHp95AmMxgY4r8gBmfjoH2ZckgGERlYYLnGOotBKSI7i1CVIHtUDpAaF8MlAFpdDsF7JJ3NyS5QmqxHMQDQvH-MTyCDpC3bADAnfw6-khs-18fT3MQkZxaiHCnFCeCieu5HX8FLJW0Vbld4azqHzvV6iSucVFTJi1AKqwERerK9DWIfC0zose7qxVHyZkCqyCO7MtebmgcFgz8G-TMEKd3KEBdjQHW4g989DJ5vUEw0r_96qW6vxxph2br8GDqh0gRdNttZbE8iRAL2dJQGEiGkx-SEOJbw4SoVYUwpjBejBIJ-rdG4cyC5ppwrlxPXTlnAcqq9Ie16k5dyg-_YEGJgAXpTkGiS_jUefF4WFy9TLA5mJmWV1UGK991Seolocw55nIqu8uewnGZrKlzn_URF3fqCCkPEu048MFp3V5B32llPFdw6ecDZM4ixH_Y6Ex2ja_KQrD-VhNLIsQT0l0xiRsuD4NxCSVzIO3oh4AR-qxkF088JN5s0b7vYNrPpc20UUPEMmP9UTsO9TnPKrxhEPZX0qoIvEdXQwWA__4p652c_mTlKFK9F-VOq0Af-rMFWJFT2H9Cstjh4J7RyLp70DgUD5IJYLysP3eEEvknyDAOnB3IbnpMF4vePgU0vTSD-QHvvd-GxNquZ1d-hGjAfcdG70mwq736OitfXAGrZBSH7tm4yOCxSllF5t1z0TIE-LuM1UKKvYFt1H8B8TI-mhgtXwx6FhLAytzRKYOQlO5A==&xkcb=SoBK6_M3hwflOvyN8p0JbzkdCdPP&p=2&camk=C3EPSzFlQw95L2GYPjH5nw==&vjs=3",
+ "tk": "1jtgbqmn921k6001"
+ },
+ "newJob": false,
+ "normTitle": "Java Developer",
+ "numHires": 1096,
+ "openInterviewsInterviewsOnTheSpot": false,
+ "openInterviewsJob": false,
+ "openInterviewsOffersOnTheSpot": false,
+ "openInterviewsPhoneJob": false,
+ "organicApplyStartCount": 47202,
+ "overrideIndeedApplyText": true,
+ "packageTier": "NONE",
+ "preciseLocationModel": {
+ "obfuscateLocation": false,
+ "overrideJCMPreciseLocationModel": true
+ },
+ "pubDate": 1778216400000,
+ "rankedBenefits": {
+ "GENERIC": [
+ "WZ9TD"
+ ],
+ "OCCUPATION_TFIDF": [
+ "WZ9TD"
+ ]
+ },
+ "rankingScoresModel": {
+ "bid": 106902,
+ "bidPosition": 3,
+ "eApply": 0.014480283,
+ "eAttainability": 0,
+ "eQualified": 0
+ },
+ "recommendationReasonModel": {
+ "reason": null
+ },
+ "redirectToThirdPartySite": false,
+ "remoteLocation": true,
+ "remoteWorkModel": {
+ "inlineText": true,
+ "text": "Remote",
+ "type": "REMOTE_ALWAYS"
+ },
+ "resultBeforeExpansion": false,
+ "resumeMatch": false,
+ "salarySnippet": {
+ "currency": "USD",
+ "salaryTextFormatted": false,
+ "source": "EXTRACTION",
+ "text": "Up to $100 an hour"
+ },
+ "saved": false,
+ "savedApplication": false,
+ "screenerQuestionsURL": "iqp://INDEXEDJOB/10128416810?sourceUrl=https%3A%2F%2Fapp.dataannotation.tech%2Fpartners%2Findeed%2Fscreener-questions%2FPROG_SA&country=US&language=en",
+ "searchUID": "1jtgbqmn921k6001",
+ "showCommutePromo": false,
+ "showEarlyApply": false,
+ "showJobType": false,
+ "showRelativeDate": true,
+ "showSponsoredLabel": false,
+ "showStrongerAppliedLabel": false,
+ "smartFillEnabled": false,
+ "smbD2iEnabled": false,
+ "snippet": "\n Help train AI chatbots while gaining the flexibility of remote work and choosing your own schedule. \n We are looking for an existing Java Developer - AI Trainer\u2026 \n ",
+ "sourceId": 44932918,
+ "sponsored": true,
+ "taxoAttributes": [
+ "Hourly pay"
+ ],
+ "taxoAttributesDisplayLimit": 3,
+ "taxoLogAttributes": [
+ "8AD9K"
+ ],
+ "taxonomyAttributes": [
+ {
+ "attributes": [
+ {
+ "label": "Full-time",
+ "suid": "CF3CP"
+ }
+ ],
+ "label": "job-types"
+ },
+ {
+ "attributes": [],
+ "label": "shifts"
+ },
+ {
+ "attributes": [
+ {
+ "label": "Remote",
+ "suid": "DSQF7"
+ }
+ ],
+ "label": "remote"
+ },
+ {
+ "attributes": [
+ {
+ "label": "Flexible schedule",
+ "suid": "WZ9TD"
+ }
+ ],
+ "label": "benefits"
+ },
+ {
+ "attributes": [
+ {
+ "label": "Full-time",
+ "suid": "CF3CP"
+ }
+ ],
+ "label": "job-types-cc"
+ },
+ {
+ "attributes": [
+ {
+ "label": "On demand",
+ "suid": "KPAXE"
+ }
+ ],
+ "label": "schedules"
+ }
+ ],
+ "thirdPartyApplyUrl": "http://www.indeed.com//applystart?jk=40da1d788bab69b0&from=jobsearch&mvj=0&jobsearchTk=1jtgbqmn921k6001&spon=1&adid=461480873&ad=-6NYlbfkN0CnM4TERr6XuVBW3VTRACvDXDx9S3Pbn6a0SwhkmoLTiLCFUpITpg4QVB7SR39MoP0BLR841cgbHYPJopI9fiQa67MfGt-TS3gF6m2xSij4aAarTk90jQV5vKgeHuaG-dJ4P0bDI4Zj_HA51wzDk11uApXr5YRiDtyBwiQNLf5d9Lyctn13orSbPQeRCVH0zqauP5hiYL4ZkfdYeGe3I1Wbq855uSspMHp95AmMxgY4r8gBmfjoH2ZckgGERlYYLnGOotBKSI7i1CVIHtUDpAaF8MlAFpdDsF7JJ3NyS5QmqxHMQDQvH-MTyCDpC3bADAnfw6-khs-18fT3MQkZxaiHCnFCeCieu5HX8FLJW0Vbld4azqHzvV6iSucVFTJi1AKqwERerK9DWIfC0zose7qxVHyZkCqyCO7MtebmgcFgz8G-TMEKd3KEBdjQHW4g989DJ5vUEw0r_96qW6vxxph2br8GDqh0gRdNttZbE8iRAL2dJQGEiGkx-SEOJbw4SoVYUwpjBejBIJ-rdG4cyC5ppwrlxPXTlnAcqq9Ie16k5dyg-_YEGJgAXpTkGiS_jUefF4WFy9TLA5mJmWV1UGK991Seolocw55nIqu8uewnGZrKlzn_URF3fqCCkPEu048MFp3V5B32llPFdw6ecDZM4ixH_Y6Ex2ja_KQrD-VhNLIsQT0l0xiRsuD4NxCSVzIO3oh4AR-qxkF088JN5s0b7vYNrPpc20UUPEMmP9UTsO9TnPKrxhEPZX0qoIvEdXQwWA__4p652c_mTlKFK9F-VOq0Af-rMFWJFT2H9Cstjh4J7RyLp70DgUD5IJYLysP3eEEvknyDAOnB3IbnpMF4vePgU0vTSD-QHvvd-GxNquZ1d-hGjAfcdG70mwq736OitfXAGrZBSH7tm4yOCxSllF5t1z0TIE-LuM1UKKvYFt1H8B8TI-mhgtXwx6FhLAytzRKYOQlO5A%3D%3D&xkcb=SoBK6_M3hwflOvyN8p0JbzkdCdPP&sjdu=Xbbwb0dbVlAPs0TIG7JumKleoDYenFeEkrm-BS2RFumAMJVRHnJnPXPQ1w4iVi_Bv12mcQcytWIaRGXVXpPFAJNHnyyAVakdybx_o10fpZKWxZ_XE6bUgpYqVY9LTx2dtS2-wQGVvKfiJXE4sfR0wlJijxkADwfaIzsQvfCpeWTbIoPN703ijAiPmVd-0iRuRqKLUPp4vg4qLycQPkInGwEJ8mAfOsifjoMlENy_ja0-fFKe71EIu7Z9bTd7syIkw1kTIG9N_3xV2F-5GZy3Y5T0JNFK-0qbOVkES4SHQ0SfH4NnnAa-f9nnHhIvvjmnptIrPbrObVWz49O6dy2-oLq1ZJUrsmKgOZzFKNCYYNCUbKfW-PqPLgNFoMJ1bqVan-wqq3U_828sw_TKiEbPLGBrX0Smwsv_nfNl645h88Ku6u2wUb1_sbeLBjNQYL-D2HF0NWgGwnwCdIgq-ovHBLz2azumxzGoE7sQwq5WgQU6Ejv27DnhAx3OksPVD9Q467ScvhtHyYiswIKxB_GqzPnKFzVfX6KlLlncqFzPtAdqUh6kbE--RtzZcE3CpSZy49vbhsBa3mK5jcKTo3kAW8OQHj80D0t3tmm_5Bz3HzmhEYtbc0mGitHUCyNLpy3-KmOMQ52bBemf2QzlsBwZM_s0P_i5BUDL-dg7xq4JOcYWtazSiL6AGCmfQ3-IZ66NzjFoAZecxXabAkK3Z3yS8YOicXGVy9B8QF5rlp7N10RoPP4JgHEDSdDTCZF8Vqkr81cMtwirsl6Ces3MDG_-lU8Qog1PeQs_BLn7VE7fnL8sFLH5stKSKIMHqtDXLFkv6ca-q2HsMP3KDd9l8Thw_GraXIfi1zP_-1dJJVesjBKRddONqBtekJ0dg4wz_0QGaCgpsJqAKTEWNu1jykb1WSIDsEyBoXQ6XBTbTgoVJg9W_pQ9pnl27ulNi4MRUUOuGkAhqGRX-suSwgxeatDCu6YGnLoNIz5_owj5ofazOCNHfLb8VF1mNhHIbmk41BvwrTMMyFcwLRwY5Zl6HRqlWTvnfRX_o71GiRqG-mo84veWnqey2tja4ynd5oGGEpJW9fAHiF_dYhtRE6_ZSH5JlkPuii5VUVatLa1wS1_UqHMTfm4yTZQ3k9qHVV5U6scDQUCqNBl8S_CEc8NZr2mHRzBoow79YUEPkNKI4KY03N1pG38fj16ZXK2GuKr1y3OpndPSDTO2ELdmaqpPn6H5V1YnWYL95pWEI_Zy8NrxXVDtYzPclQ0xpJHTWANEIb5vatND9npl3p-ADGXXrn291chTmlJv9lkd31Zosvlcvtn6BtL5lqZmS4Z29H6UBAhS-m4v2dwNroX8_PDn_lwGbwDY1d5uBB3EHNFQRlK-uGR-5UJ1eDDjSJ6uQi4hk6R-Z8H9h0Q5tm1cqEFUnMMiQpxZ6pdvi2b3fmxnjLYK0Ro&vjfrom=vjs&astse=da7fff3285780cc4&assa=589",
+ "tier": {
+ "matchedPreferences": {
+ "longMatchedPreferences": [],
+ "stringMatchedPreferences": []
+ },
+ "type": "DEFAULT"
+ },
+ "title": "Java Developer - AI Trainer",
+ "translatedAttributes": [],
+ "translatedCmiJobTags": [],
+ "truncatedCompany": "DataAnnotation",
+ "urgentlyHiring": false,
+ "viewJobLink": "/viewjob?jk=40da1d788bab69b0&from=vjs&tk=1jtgbqmn921k6001&viewtype=embedded&advn=7831672186689475&adid=461480873&ad=-6NYlbfkN0CnM4TERr6XuVBW3VTRACvDXDx9S3Pbn6a0SwhkmoLTiLCFUpITpg4QVB7SR39MoP0BLR841cgbHYPJopI9fiQa67MfGt-TS3gF6m2xSij4aAarTk90jQV5vKgeHuaG-dJ4P0bDI4Zj_HA51wzDk11uApXr5YRiDtyBwiQNLf5d9Lyctn13orSbPQeRCVH0zqauP5hiYL4ZkfdYeGe3I1Wbq855uSspMHp95AmMxgY4r8gBmfjoH2ZckgGERlYYLnGOotBKSI7i1CVIHtUDpAaF8MlAFpdDsF7JJ3NyS5QmqxHMQDQvH-MTyCDpC3bADAnfw6-khs-18fT3MQkZxaiHCnFCeCieu5HX8FLJW0Vbld4azqHzvV6iSucVFTJi1AKqwERerK9DWIfC0zose7qxVHyZkCqyCO7MtebmgcFgz8G-TMEKd3KEBdjQHW4g989DJ5vUEw0r_96qW6vxxph2br8GDqh0gRdNttZbE8iRAL2dJQGEiGkx-SEOJbw4SoVYUwpjBejBIJ-rdG4cyC5ppwrlxPXTlnAcqq9Ie16k5dyg-_YEGJgAXpTkGiS_jUefF4WFy9TLA5mJmWV1UGK991Seolocw55nIqu8uewnGZrKlzn_URF3fqCCkPEu048MFp3V5B32llPFdw6ecDZM4ixH_Y6Ex2ja_KQrD-VhNLIsQT0l0xiRsuD4NxCSVzIO3oh4AR-qxkF088JN5s0b7vYNrPpc20UUPEMmP9UTsO9TnPKrxhEPZX0qoIvEdXQwWA__4p652c_mTlKFK9F-VOq0Af-rMFWJFT2H9Cstjh4J7RyLp70DgUD5IJYLysP3eEEvknyDAOnB3IbnpMF4vePgU0vTSD-QHvvd-GxNquZ1d-hGjAfcdG70mwq736OitfXAGrZBSH7tm4yOCxSllF5t1z0TIE-LuM1UKKvYFt1H8B8TI-mhgtXwx6FhLAytzRKYOQlO5A%3D%3D&xkcb=SoBK6_M3hwflOvyN8p0JbzkdCdPP&continueUrl=%2Fjobs%3Fq%3Dsoftware%2Bengineer%26l%3DRemote",
+ "vjFeaturedEmployerCandidate": false
+ }
+ ],
+ "pageNumber": 1,
+ "what": "software engineer",
+ "where": "Remote"
+ }
+ }
+}
\ No newline at end of file
From 37963c64f1bd13c6e365e6e19dae38b6b0213bdf Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:42:00 +0200
Subject: [PATCH 014/217] test: indeed parsers
---
.../platforms/indeed_jobs/test_parsers.py | 139 ++++++++++++++++++
1 file changed, 139 insertions(+)
create mode 100644 surfsense_backend/tests/unit/platforms/indeed_jobs/test_parsers.py
diff --git a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_parsers.py b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_parsers.py
new file mode 100644
index 000000000..7b9ca6298
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_parsers.py
@@ -0,0 +1,139 @@
+"""Offline parser tests: synthetic mapping plus a real captured blob."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from app.proprietary.platforms.indeed_jobs.parsers import (
+ extract_jobcards_blob,
+ job_results,
+ parse_job,
+)
+
+_FIXTURE_DIR = Path(__file__).parent / "fixtures"
+
+
+# --- synthetic mapping (always runs) ---------------------------------------
+
+
+def _raw_job() -> dict:
+ return {
+ "jobkey": "abc123",
+ "displayTitle": "Senior Data Analyst",
+ "title": "Senior Data Analyst (fallback)",
+ "company": "Acme Corp",
+ "truncatedCompany": "Acme",
+ "companyOverviewLink": "/cmp/Acme-Corp",
+ "companyRating": 4.1,
+ "companyReviewCount": 320,
+ "formattedLocation": "New York, NY",
+ "jobLocationCity": "New York",
+ "jobLocationState": "NY",
+ "jobLocationPostal": "10001",
+ "country": "US",
+ "remoteLocation": False,
+ "remoteWorkModel": {"type": "REMOTE_ALWAYS", "text": "Remote"},
+ "jobTypes": [],
+ "salarySnippet": {"currency": "USD", "text": "$90,000 - $120,000 a year"},
+ "extractedSalary": {"min": 90000, "max": 120000, "type": "YEARLY"},
+ "snippet": "",
+ "sponsored": True,
+ "newJob": False,
+ "urgentlyHiring": True,
+ "expired": False,
+ "indeedApplyEnabled": True,
+ "formattedRelativeTime": "3 days ago",
+ "pubDate": 1_774_242_000_000,
+ "createDate": 1_774_276_267_415,
+ "thirdPartyApplyUrl": "https://ats.example.com/apply/abc123",
+ "taxonomyAttributes": [
+ {"label": "job-types", "attributes": [{"label": "Full-time", "suid": "x"}]},
+ {"label": "remote", "attributes": [{"label": "Remote", "suid": "y"}]},
+ {
+ "label": "benefits",
+ "attributes": [
+ {"label": "Health insurance", "suid": "a"},
+ {"label": "401(k)", "suid": "b"},
+ ],
+ },
+ ],
+ }
+
+
+def test_parse_job_maps_core_fields():
+ item = parse_job(_raw_job())
+ assert item["jobKey"] == "abc123"
+ assert item["title"] == "Senior Data Analyst" # displayTitle wins over title
+ assert item["jobUrl"] == "https://www.indeed.com/viewjob?jk=abc123"
+ assert item["applyUrl"] == "https://ats.example.com/apply/abc123"
+ assert item["company"] == "Acme Corp"
+ assert item["companyUrl"] == "https://www.indeed.com/cmp/Acme-Corp"
+ assert item["companyReviewCount"] == 320
+ assert item["city"] == "New York"
+ assert item["isRemote"] is True
+ assert item["remoteType"] == "REMOTE_ALWAYS"
+ assert item["jobTypes"] == ["Full-time"]
+ assert item["benefits"] == ["Health insurance", "401(k)"]
+ assert item["sponsored"] is True
+ assert item["urgentlyHiring"] is True
+
+
+def test_parse_job_salary_and_snippet():
+ item = parse_job(_raw_job())
+ sal = item["salary"]
+ assert sal["salaryText"] == "$90,000 - $120,000 a year"
+ assert sal["salaryMin"] == 90000
+ assert sal["salaryMax"] == 120000
+ assert sal["currency"] == "USD"
+ assert sal["period"] == "year"
+ assert sal["isEstimated"] is False
+ # snippet HTML is stripped + entities decoded into plain text.
+ assert item["descriptionText"] == "5+ years SQL & Python"
+ assert item["descriptionHtml"] is None
+
+
+def test_parse_job_dates_from_epoch_ms():
+ item = parse_job(_raw_job())
+ assert item["datePublished"] == "2026-03-23T05:00:00.000Z"
+ assert item["age"] == "3 days ago"
+
+
+def test_parse_job_respects_base_url():
+ item = parse_job(_raw_job(), base_url="https://uk.indeed.com")
+ assert item["jobUrl"] == "https://uk.indeed.com/viewjob?jk=abc123"
+ assert item["companyUrl"] == "https://uk.indeed.com/cmp/Acme-Corp"
+
+
+def test_extract_blob_anchors_on_assignment_not_first_occurrence():
+ # Decoy mention precedes the real assignment; the extractor must skip it.
+ html = (
+ ''
+ ''
+ )
+ blob = extract_jobcards_blob(html)
+ results = job_results(blob)
+ assert [r["jobkey"] for r in results] == ["k1", "k2"]
+
+
+def test_extract_blob_missing_returns_none():
+ assert extract_jobcards_blob("just a moment...") is None
+ assert job_results(None) == []
+
+
+# --- fixture-pinned (real captured blob) -----------------------------------
+
+
+def test_fixture_blob_parses_into_items():
+ fixture = _FIXTURE_DIR / "sample_jobcards.json"
+ blob = json.loads(fixture.read_text())
+ results = job_results(blob)
+ assert len(results) == 3
+ for raw in results:
+ item = parse_job(raw)
+ assert isinstance(item["jobKey"], str) and item["jobKey"]
+ assert item["title"]
+ assert item["jobUrl"].startswith("https://www.indeed.com/viewjob?jk=")
+ assert "salaryText" in item["salary"]
From 91e868fc26143b1307dec9cdb505d12ccb52abea Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:42:00 +0200
Subject: [PATCH 015/217] test: indeed url resolver
---
.../indeed_jobs/test_url_resolver.py | 87 +++++++++++++++++++
1 file changed, 87 insertions(+)
create mode 100644 surfsense_backend/tests/unit/platforms/indeed_jobs/test_url_resolver.py
diff --git a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_url_resolver.py b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_url_resolver.py
new file mode 100644
index 000000000..b995b3d0b
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_url_resolver.py
@@ -0,0 +1,87 @@
+"""Offline tests for Indeed URL classification and search-URL building."""
+
+from __future__ import annotations
+
+from urllib.parse import parse_qs, urlparse
+
+from app.proprietary.platforms.indeed_jobs.url_resolver import (
+ build_search_url,
+ country_domain,
+ resolve_url,
+)
+
+
+def test_resolve_search_url():
+ r = resolve_url(
+ "https://www.indeed.com/jobs?q=software+engineer&l=Remote&sort=date"
+ )
+ assert r is not None
+ assert r.kind == "search"
+ assert r.value == "software engineer"
+ assert r.location == "Remote"
+ assert r.domain == "www.indeed.com"
+ assert r.params.get("sort") == "date"
+
+
+def test_resolve_company_url():
+ r = resolve_url("https://www.indeed.com/cmp/Google/jobs")
+ assert r is not None
+ assert r.kind == "company"
+ assert r.value == "Google"
+
+
+def test_resolve_viewjob_url():
+ r = resolve_url("https://uk.indeed.com/viewjob?jk=abc123&from=serp")
+ assert r is not None
+ assert r.kind == "job"
+ assert r.value == "abc123"
+ assert r.domain == "uk.indeed.com"
+
+
+def test_resolve_country_subdomain_host():
+ r = resolve_url("https://de.indeed.com/jobs?q=entwickler")
+ assert r is not None
+ assert r.kind == "search"
+ assert r.domain == "de.indeed.com"
+
+
+def test_resolve_rejects_non_indeed():
+ assert resolve_url("https://www.linkedin.com/jobs?q=dev") is None
+ assert resolve_url("https://notindeed.com.evil.com/jobs") is None
+
+
+def test_country_domain_map():
+ assert country_domain("us") == "www.indeed.com"
+ assert country_domain("gb") == "uk.indeed.com"
+ assert country_domain("de") == "de.indeed.com"
+ assert country_domain("") == "www.indeed.com"
+
+
+def test_build_search_url_basic():
+ url = build_search_url(
+ "data analyst",
+ country="us",
+ location="New York, NY",
+ sort="date",
+ start=20,
+ )
+ parsed = urlparse(url)
+ qs = parse_qs(parsed.query)
+ assert parsed.netloc == "www.indeed.com"
+ assert parsed.path == "/jobs"
+ assert qs["q"] == ["data analyst"]
+ assert qs["l"] == ["New York, NY"]
+ assert qs["sort"] == ["date"]
+ assert qs["start"] == ["20"]
+
+
+def test_build_search_url_remote_keyword_fallback_and_jobtype():
+ url = build_search_url(
+ "developer", country="gb", remote="remote", job_type="fulltime", from_days=7
+ )
+ parsed = urlparse(url)
+ qs = parse_qs(parsed.query)
+ assert parsed.netloc == "uk.indeed.com"
+ assert qs["q"] == ["developer remote"]
+ assert qs["jt"] == ["fulltime"]
+ assert qs["fromage"] == ["7"]
From a60aba112c52ae744d9d20204548bea65188d198 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:42:00 +0200
Subject: [PATCH 016/217] test: indeed fetch rotation resilience
---
.../indeed_jobs/test_fetch_resilience.py | 95 +++++++++++++++++++
1 file changed, 95 insertions(+)
create mode 100644 surfsense_backend/tests/unit/platforms/indeed_jobs/test_fetch_resilience.py
diff --git a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_fetch_resilience.py b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_fetch_resilience.py
new file mode 100644
index 000000000..4eae65f84
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_fetch_resilience.py
@@ -0,0 +1,95 @@
+"""Offline tests for the rotate-on-block fetch loop (no network, fake session)."""
+
+from __future__ import annotations
+
+import pytest
+
+from app.proprietary.platforms.indeed_jobs.fetch import (
+ IndeedAccessBlockedError,
+ IndeedSession,
+)
+
+_OK_HTML = "jobs listing"
+_BLOCK_HTML = "secure.indeed.com security check"
+
+
+class _FakePage:
+ def __init__(self, html: str, url: str) -> None:
+ self.html_content = html
+ self.url = url
+
+
+class _Controller:
+ """Shared state across sessions the factory hands out (survives rotation)."""
+
+ def __init__(self, target_outcomes: list[str]) -> None:
+ self.target_outcomes = target_outcomes
+ self.sessions_started = 0
+ self.home_fetches: dict[str, int] = {}
+ self.target_index = 0
+
+ def factory(self) -> _FakeSession:
+ return _FakeSession(self)
+
+
+class _FakeSession:
+ def __init__(self, ctrl: _Controller) -> None:
+ self._ctrl = ctrl
+
+ async def start(self) -> None:
+ self._ctrl.sessions_started += 1
+
+ async def close(self) -> None:
+ pass
+
+ async def fetch(self, url: str, **_: object) -> _FakePage:
+ if url.endswith("/") and "/jobs" not in url: # warm-up hit
+ self._ctrl.home_fetches[url] = self._ctrl.home_fetches.get(url, 0) + 1
+ return _FakePage("home", url)
+ outcome = self._ctrl.target_outcomes[self._ctrl.target_index]
+ self._ctrl.target_index += 1
+ if outcome == "OK":
+ return _FakePage(_OK_HTML, url)
+ if outcome == "ERROR":
+ raise RuntimeError("boom")
+ return _FakePage(_BLOCK_HTML, "https://secure.indeed.com/auth")
+
+
+_URL = "https://www.indeed.com/jobs?q=dev"
+
+
+@pytest.mark.asyncio
+async def test_rotates_past_a_block_then_succeeds():
+ ctrl = _Controller(["BLOCK", "OK"])
+ session = IndeedSession(ctrl.factory)
+ html = await session.fetch_html(_URL)
+ assert html == _OK_HTML
+ assert session.rotations == 1
+ assert ctrl.sessions_started == 2 # initial + one rotation
+
+
+@pytest.mark.asyncio
+async def test_recovers_after_a_fetch_error():
+ ctrl = _Controller(["ERROR", "OK"])
+ session = IndeedSession(ctrl.factory)
+ assert await session.fetch_html(_URL) == _OK_HTML
+ assert session.rotations == 1
+
+
+@pytest.mark.asyncio
+async def test_raises_after_exhausting_rotations():
+ ctrl = _Controller(["BLOCK"] * 10)
+ session = IndeedSession(ctrl.factory)
+ with pytest.raises(IndeedAccessBlockedError):
+ await session.fetch_html(_URL)
+ assert session.rotations == 3
+
+
+@pytest.mark.asyncio
+async def test_warms_domain_once_without_rotation():
+ ctrl = _Controller(["OK", "OK"])
+ session = IndeedSession(ctrl.factory)
+ await session.fetch_html(_URL)
+ await session.fetch_html(_URL + "&start=10")
+ assert ctrl.home_fetches["https://www.indeed.com/"] == 1
+ await session.close()
From 063faf4bd6f92e7851ad76a725d53e68eca3d586 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:42:00 +0200
Subject: [PATCH 017/217] test: indeed scraper orchestration
---
.../platforms/indeed_jobs/test_scraper.py | 105 ++++++++++++++++++
1 file changed, 105 insertions(+)
create mode 100644 surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py
diff --git a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py
new file mode 100644
index 000000000..c56d64f75
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py
@@ -0,0 +1,105 @@
+"""Offline orchestration tests: pagination, dedupe, and caps via a fake session."""
+
+from __future__ import annotations
+
+import json
+from urllib.parse import parse_qs, urlparse
+
+import pytest
+
+from app.proprietary.platforms.indeed_jobs.schemas import IndeedScrapeInput
+from app.proprietary.platforms.indeed_jobs.scraper import iter_indeed, scrape_indeed
+
+
+def _page_html(job_keys: list[str]) -> str:
+ """Wrap job keys in the ``mosaic-provider-jobcards`` assignment shape."""
+ results = [
+ {"jobkey": k, "displayTitle": f"Job {k}", "company": "Acme"} for k in job_keys
+ ]
+ model = {"metaData": {"mosaicProviderJobCardsModel": {"results": results}}}
+ return (
+ f'window.mosaic.providerData["mosaic-provider-jobcards"]={json.dumps(model)};'
+ )
+
+
+class _FakeSession:
+ """Returns per-``start`` pages and records the URLs requested."""
+
+ def __init__(self, pages: dict[int, list[str]]) -> None:
+ self._pages = pages
+ self.fetched: list[str] = []
+
+ async def fetch_html(self, url: str) -> str:
+ self.fetched.append(url)
+ start = int(parse_qs(urlparse(url).query).get("start", ["0"])[0])
+ return _page_html(self._pages.get(start, []))
+
+
+async def _collect(input_model, session) -> list[dict]:
+ return [item async for item in iter_indeed(input_model, session)]
+
+
+@pytest.mark.asyncio
+async def test_paginates_and_dedupes_across_pages():
+ session = _FakeSession({0: ["k1", "k2", "k3"], 10: ["k3", "k4"], 20: []})
+ items = await _collect(
+ IndeedScrapeInput(queries=["dev"], maxItemsPerQuery=100), session
+ )
+ assert [i["jobKey"] for i in items] == ["k1", "k2", "k3", "k4"]
+ assert all(i["scrapedAt"] for i in items) # stamped by the orchestrator
+
+
+@pytest.mark.asyncio
+async def test_stops_when_a_page_is_all_duplicates():
+ session = _FakeSession({0: ["k1", "k2"], 10: ["k1", "k2"], 20: ["k9"]})
+ items = await _collect(
+ IndeedScrapeInput(queries=["dev"], maxItemsPerQuery=100), session
+ )
+ assert [i["jobKey"] for i in items] == ["k1", "k2"]
+ assert 20 not in {
+ int(parse_qs(urlparse(u).query).get("start", ["0"])[0]) for u in session.fetched
+ }
+
+
+@pytest.mark.asyncio
+async def test_respects_max_items_per_query():
+ session = _FakeSession({0: ["k1", "k2", "k3", "k4"]})
+ items = await _collect(
+ IndeedScrapeInput(queries=["dev"], maxItemsPerQuery=2), session
+ )
+ assert [i["jobKey"] for i in items] == ["k1", "k2"]
+
+
+@pytest.mark.asyncio
+async def test_global_dedupe_across_queries():
+ # Both queries hit page 0 (same fake pages) and return the same keys.
+ session = _FakeSession({0: ["k1", "k2"]})
+ items = await _collect(
+ IndeedScrapeInput(queries=["dev", "engineer"], maxItemsPerQuery=100), session
+ )
+ assert [i["jobKey"] for i in items] == ["k1", "k2"]
+
+
+@pytest.mark.asyncio
+async def test_start_urls_skip_viewjob_and_scrape_search():
+ session = _FakeSession({0: ["k1"]})
+ input_model = IndeedScrapeInput(
+ startUrls=[
+ {"url": "https://www.indeed.com/jobs?q=dev"},
+ {"url": "https://www.indeed.com/viewjob?jk=abc"},
+ ],
+ maxItemsPerQuery=100,
+ )
+ items = await _collect(input_model, session)
+ assert [i["jobKey"] for i in items] == ["k1"]
+
+
+@pytest.mark.asyncio
+async def test_scrape_indeed_limit_with_injected_session():
+ session = _FakeSession({0: ["k1", "k2", "k3"]})
+ items = await scrape_indeed(
+ IndeedScrapeInput(queries=["dev"], maxItemsPerQuery=100),
+ limit=2,
+ session=session,
+ )
+ assert [i["jobKey"] for i in items] == ["k1", "k2"]
From 05bfc6a10ef521204b83fc3a68346f7402ef5b88 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:49:23 +0200
Subject: [PATCH 018/217] feat: add INDEED_JOB billing unit
---
surfsense_backend/app/capabilities/core/types.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/surfsense_backend/app/capabilities/core/types.py b/surfsense_backend/app/capabilities/core/types.py
index 67b9175fc..6b3be70d0 100644
--- a/surfsense_backend/app/capabilities/core/types.py
+++ b/surfsense_backend/app/capabilities/core/types.py
@@ -30,6 +30,7 @@ class BillingUnit(StrEnum):
TIKTOK_VIDEO = "tiktok_video"
TIKTOK_USER = "tiktok_user"
TIKTOK_COMMENT = "tiktok_comment"
+ INDEED_JOB = "indeed_job"
class BillableInput(Protocol):
From e25d09c25e33fab2f583ffce4e01fb19153bec7e Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:49:23 +0200
Subject: [PATCH 019/217] feat: add indeed per-job scrape rate config
---
surfsense_backend/app/config/__init__.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py
index 92e448d9a..c006c8cc6 100644
--- a/surfsense_backend/app/config/__init__.py
+++ b/surfsense_backend/app/config/__init__.py
@@ -736,6 +736,11 @@ class Config:
# Comments are the cheapest per-item TikTok data, matching the per-comment
# market (and YouTube's comment meter).
TIKTOK_MICROS_PER_COMMENT = int(os.getenv("TIKTOK_MICROS_PER_COMMENT", "1500"))
+ # Warmed-browser listings put Indeed on par with the other browser-driven
+ # scrapers (Reddit, Instagram) rather than the cheaper API-backed meters.
+ INDEED_SCRAPE_MICROS_PER_JOB = int(
+ os.getenv("INDEED_SCRAPE_MICROS_PER_JOB", "3500")
+ )
# Retry an empty listing draw on a fresh rotating IP. Set to 1 for a static
# proxy, where every retry re-hits the same exit.
TIKTOK_LISTING_MAX_ATTEMPTS = int(os.getenv("TIKTOK_LISTING_MAX_ATTEMPTS", "3"))
From d89ca141a4b914ff342ab68c58cd2cf6bd408df4 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:49:23 +0200
Subject: [PATCH 020/217] feat: meter indeed_job billing unit
---
surfsense_backend/app/capabilities/core/billing.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/surfsense_backend/app/capabilities/core/billing.py b/surfsense_backend/app/capabilities/core/billing.py
index 67abf4164..bc031db3f 100644
--- a/surfsense_backend/app/capabilities/core/billing.py
+++ b/surfsense_backend/app/capabilities/core/billing.py
@@ -40,6 +40,7 @@ _PLATFORM_RATE_KEYS: dict[BillingUnit, str] = {
BillingUnit.TIKTOK_VIDEO: "TIKTOK_MICROS_PER_VIDEO",
BillingUnit.TIKTOK_USER: "TIKTOK_MICROS_PER_USER",
BillingUnit.TIKTOK_COMMENT: "TIKTOK_MICROS_PER_COMMENT",
+ BillingUnit.INDEED_JOB: "INDEED_SCRAPE_MICROS_PER_JOB",
}
@@ -61,6 +62,7 @@ _UNIT_NOUNS: dict[BillingUnit, str] = {
BillingUnit.TIKTOK_VIDEO: "video",
BillingUnit.TIKTOK_USER: "profile",
BillingUnit.TIKTOK_COMMENT: "comment",
+ BillingUnit.INDEED_JOB: "job",
}
From e887c1cae718fa0b7ca4ca1a57c04a2d82bdf4ae Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:49:23 +0200
Subject: [PATCH 021/217] feat: indeed.scrape io schemas
---
.../app/capabilities/indeed/scrape/schemas.py | 112 ++++++++++++++++++
1 file changed, 112 insertions(+)
create mode 100644 surfsense_backend/app/capabilities/indeed/scrape/schemas.py
diff --git a/surfsense_backend/app/capabilities/indeed/scrape/schemas.py b/surfsense_backend/app/capabilities/indeed/scrape/schemas.py
new file mode 100644
index 000000000..a02f6f13b
--- /dev/null
+++ b/surfsense_backend/app/capabilities/indeed/scrape/schemas.py
@@ -0,0 +1,112 @@
+"""``indeed.scrape`` I/O contracts.
+
+A lean, agent-friendly surface over ``IndeedScrapeInput``
+(``app/proprietary/platforms/indeed_jobs``). The executor maps this to the full
+scraper input; the scraper's ``IndeedItem`` is reused verbatim as the output
+element.
+"""
+
+from __future__ import annotations
+
+from pydantic import BaseModel, Field, model_validator
+
+from app.proprietary.platforms.indeed_jobs import IndeedItem
+from app.proprietary.platforms.indeed_jobs.schemas import (
+ IndeedJobType,
+ IndeedLevel,
+ IndeedRemote,
+ IndeedSort,
+)
+
+MAX_INDEED_SOURCES = 20
+"""Per-call cap on urls + search_queries: bounds a synchronous request's fan-out."""
+
+MAX_INDEED_ITEMS = 100
+"""Hard ceiling on jobs returned per call, regardless of the per-query caps."""
+
+
+class ScrapeInput(BaseModel):
+ urls: list[str] = Field(
+ default_factory=list,
+ max_length=MAX_INDEED_SOURCES,
+ description=(
+ "Indeed URLs to scrape: a search page (/jobs?q=&l=) or a company "
+ "jobs page (/cmp//jobs). Provide these OR search_queries "
+ "(at least one source is required)."
+ ),
+ )
+ search_queries: list[str] = Field(
+ default_factory=list,
+ max_length=MAX_INDEED_SOURCES,
+ description=(
+ "Job search terms; each returns up to max_items_per_query results, "
+ "shaped by country/location/job_type/etc."
+ ),
+ )
+ country: str = Field(
+ default="us",
+ description="Country code selecting the Indeed domain, e.g. 'us', 'gb', 'de'.",
+ )
+ location: str | None = Field(
+ default=None,
+ description="Where to search, e.g. 'Remote', 'New York, NY'.",
+ )
+ radius: int | None = Field(
+ default=None,
+ description="Search radius in miles/km around location.",
+ )
+ job_type: IndeedJobType | None = Field(
+ default=None,
+ description="Employment type filter: fulltime, parttime, contract, etc.",
+ )
+ level: IndeedLevel | None = Field(
+ default=None,
+ description="Experience level filter: entry_level, mid_level, senior_level.",
+ )
+ remote: IndeedRemote | None = Field(
+ default=None,
+ description="Work model filter: remote or hybrid.",
+ )
+ from_days: int | None = Field(
+ default=None,
+ description="Only return jobs posted within the last N days.",
+ )
+ sort: IndeedSort = Field(
+ default="relevance",
+ description="Result ordering: relevance or date.",
+ )
+ max_items: int = Field(
+ default=25,
+ ge=1,
+ le=MAX_INDEED_ITEMS,
+ description="Max total jobs to return across all sources.",
+ )
+ max_items_per_query: int = Field(
+ default=25,
+ ge=0,
+ description="Max jobs to pull per search/company target.",
+ )
+
+ @model_validator(mode="after")
+ def _require_a_source(self) -> ScrapeInput:
+ if not self.urls and not self.search_queries:
+ raise ValueError("Provide at least one of 'urls' or 'search_queries'.")
+ return self
+
+ @property
+ def estimated_units(self) -> int:
+ """Worst-case billable jobs for the pre-flight gate: ``max_items`` is a
+ hard cross-source ceiling (le=100), so no call can exceed it."""
+ return self.max_items
+
+
+class ScrapeOutput(BaseModel):
+ items: list[IndeedItem] = Field(
+ default_factory=list,
+ description="One item per job posting, in emission order.",
+ )
+
+ @property
+ def billable_units(self) -> int:
+ """One returned job = one billable unit."""
+ return len(self.items)
From b457599fd4577a65dc1fba61288574e1ba3c6204 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:49:23 +0200
Subject: [PATCH 022/217] feat: indeed.scrape executor
---
.../capabilities/indeed/scrape/executor.py | 55 +++++++++++++++++++
1 file changed, 55 insertions(+)
create mode 100644 surfsense_backend/app/capabilities/indeed/scrape/executor.py
diff --git a/surfsense_backend/app/capabilities/indeed/scrape/executor.py b/surfsense_backend/app/capabilities/indeed/scrape/executor.py
new file mode 100644
index 000000000..60df11972
--- /dev/null
+++ b/surfsense_backend/app/capabilities/indeed/scrape/executor.py
@@ -0,0 +1,55 @@
+"""``indeed.scrape`` executor: verb input → scraper → Indeed job items."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+
+from app.capabilities.core import Executor
+from app.capabilities.core.progress import emit_progress
+from app.capabilities.indeed.scrape.schemas import ScrapeInput, ScrapeOutput
+from app.exceptions import ForbiddenError
+from app.proprietary.platforms.indeed_jobs import (
+ IndeedAccessBlockedError,
+ IndeedScrapeInput,
+ scrape_indeed,
+)
+
+ScrapeFn = Callable[..., Awaitable[list[dict]]]
+
+
+def build_scrape_executor(scrape_fn: ScrapeFn | None = None) -> Executor:
+ """Bind the executor to a scraper fn (defaults to the proprietary actor)."""
+ scrape_fn = scrape_fn or scrape_indeed
+
+ async def execute(payload: ScrapeInput) -> ScrapeOutput:
+ actor_input = IndeedScrapeInput(
+ startUrls=[{"url": url} for url in payload.urls],
+ queries=payload.search_queries,
+ country=payload.country,
+ location=payload.location,
+ radius=payload.radius,
+ jobType=payload.job_type,
+ level=payload.level,
+ remote=payload.remote,
+ fromDays=payload.from_days,
+ sort=payload.sort,
+ maxItems=payload.max_items,
+ maxItemsPerQuery=payload.max_items_per_query,
+ )
+ emit_progress(
+ "starting", "Resolving Indeed targets", total=payload.max_items, unit="job"
+ )
+ try:
+ items = await scrape_fn(actor_input, limit=payload.max_items)
+ except IndeedAccessBlockedError as exc:
+ # Anonymous-only scraper; a hard block can't be retried with creds.
+ raise ForbiddenError(
+ f"Indeed refused anonymous access: {exc}",
+ code="INDEED_ACCESS_BLOCKED",
+ ) from exc
+ emit_progress(
+ "done", f"Scraped {len(items)} job(s)", current=len(items), unit="job"
+ )
+ return ScrapeOutput(items=items)
+
+ return execute
From 3f91a006cd98967a03df3b72ab706957d3c83a2b Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:49:23 +0200
Subject: [PATCH 023/217] feat: register indeed.scrape capability
---
.../capabilities/indeed/scrape/definition.py | 23 +++++++++++++++++++
1 file changed, 23 insertions(+)
create mode 100644 surfsense_backend/app/capabilities/indeed/scrape/definition.py
diff --git a/surfsense_backend/app/capabilities/indeed/scrape/definition.py b/surfsense_backend/app/capabilities/indeed/scrape/definition.py
new file mode 100644
index 000000000..b831cd4a7
--- /dev/null
+++ b/surfsense_backend/app/capabilities/indeed/scrape/definition.py
@@ -0,0 +1,23 @@
+"""``indeed.scrape`` capability registration (billed per job; see config
+``INDEED_SCRAPE_MICROS_PER_JOB``)."""
+
+from __future__ import annotations
+
+from app.capabilities.core import BillingUnit, Capability, register_capability
+from app.capabilities.indeed.scrape.executor import build_scrape_executor
+from app.capabilities.indeed.scrape.schemas import ScrapeInput, ScrapeOutput
+
+INDEED_SCRAPE = Capability(
+ name="indeed.scrape",
+ description=(
+ "Scrape public Indeed job postings, including title, company, location, "
+ "salary, and description. Use urls or search_queries."
+ ),
+ input_schema=ScrapeInput,
+ output_schema=ScrapeOutput,
+ executor=build_scrape_executor(),
+ billing_unit=BillingUnit.INDEED_JOB,
+ docs_url="/docs/connectors/native/indeed",
+)
+
+register_capability(INDEED_SCRAPE)
From 17159172ee28e5cfefaae84e2f9f7a2f3e340cff Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:49:23 +0200
Subject: [PATCH 024/217] feat: indeed.scrape verb package
---
surfsense_backend/app/capabilities/indeed/scrape/__init__.py | 3 +++
1 file changed, 3 insertions(+)
create mode 100644 surfsense_backend/app/capabilities/indeed/scrape/__init__.py
diff --git a/surfsense_backend/app/capabilities/indeed/scrape/__init__.py b/surfsense_backend/app/capabilities/indeed/scrape/__init__.py
new file mode 100644
index 000000000..6b7fa588e
--- /dev/null
+++ b/surfsense_backend/app/capabilities/indeed/scrape/__init__.py
@@ -0,0 +1,3 @@
+"""``indeed.scrape`` verb: Indeed search / company URLs → job postings."""
+
+from __future__ import annotations
From 8c410d8682097801f524293bbc25b34feae6e014 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:49:23 +0200
Subject: [PATCH 025/217] feat: indeed capability namespace
---
surfsense_backend/app/capabilities/indeed/__init__.py | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 surfsense_backend/app/capabilities/indeed/__init__.py
diff --git a/surfsense_backend/app/capabilities/indeed/__init__.py b/surfsense_backend/app/capabilities/indeed/__init__.py
new file mode 100644
index 000000000..5e053244f
--- /dev/null
+++ b/surfsense_backend/app/capabilities/indeed/__init__.py
@@ -0,0 +1,5 @@
+"""``indeed.*`` namespace: platform-native Indeed data verbs."""
+
+from __future__ import annotations
+
+from app.capabilities.indeed.scrape import definition as _scrape # noqa: F401
From ad49e9004e33a11b8b333dafb3d187e01c189e60 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:49:23 +0200
Subject: [PATCH 026/217] feat: register indeed namespace on routes
---
surfsense_backend/app/routes/__init__.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py
index 85450d4b7..b5186a72f 100644
--- a/surfsense_backend/app/routes/__init__.py
+++ b/surfsense_backend/app/routes/__init__.py
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
# Import verb namespaces for their registration side effects before the door builds.
import app.capabilities.google_maps
import app.capabilities.google_search
+import app.capabilities.indeed
import app.capabilities.instagram
import app.capabilities.reddit
import app.capabilities.tiktok
From 4171248f512b9b343913cbd27627a1f185280281 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:49:31 +0200
Subject: [PATCH 027/217] test: indeed capability test package
---
surfsense_backend/tests/unit/capabilities/indeed/__init__.py | 0
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 surfsense_backend/tests/unit/capabilities/indeed/__init__.py
diff --git a/surfsense_backend/tests/unit/capabilities/indeed/__init__.py b/surfsense_backend/tests/unit/capabilities/indeed/__init__.py
new file mode 100644
index 000000000..e69de29bb
From 725cb8a82b153a154d27d10469e93b2f3ab35e9f Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:49:31 +0200
Subject: [PATCH 028/217] test: indeed.scrape test package
---
.../tests/unit/capabilities/indeed/scrape/__init__.py | 0
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 surfsense_backend/tests/unit/capabilities/indeed/scrape/__init__.py
diff --git a/surfsense_backend/tests/unit/capabilities/indeed/scrape/__init__.py b/surfsense_backend/tests/unit/capabilities/indeed/scrape/__init__.py
new file mode 100644
index 000000000..e69de29bb
From d029bc0cb806312610473c82e9cfa1392c04d49b Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:49:31 +0200
Subject: [PATCH 029/217] test: indeed.scrape registration
---
.../unit/capabilities/indeed/test_registry.py | 23 +++++++++++++++++++
1 file changed, 23 insertions(+)
create mode 100644 surfsense_backend/tests/unit/capabilities/indeed/test_registry.py
diff --git a/surfsense_backend/tests/unit/capabilities/indeed/test_registry.py b/surfsense_backend/tests/unit/capabilities/indeed/test_registry.py
new file mode 100644
index 000000000..08bc8d7cf
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/indeed/test_registry.py
@@ -0,0 +1,23 @@
+"""The indeed namespace registers its verb as one Capability the doors/agent read."""
+
+from __future__ import annotations
+
+import pytest
+
+from app.capabilities import (
+ indeed, # noqa: F401 — importing the namespace registers its verbs
+)
+from app.capabilities.core.store import get_capability
+from app.capabilities.core.types import BillingUnit
+from app.capabilities.indeed.scrape.schemas import ScrapeInput, ScrapeOutput
+
+pytestmark = pytest.mark.unit
+
+
+def test_indeed_scrape_is_registered_and_billed_per_job():
+ cap = get_capability("indeed.scrape")
+
+ assert cap.name == "indeed.scrape"
+ assert cap.input_schema is ScrapeInput
+ assert cap.output_schema is ScrapeOutput
+ assert cap.billing_unit is BillingUnit.INDEED_JOB
From 695a7d69e0c0500836917ae57b185dff43cb49c1 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:49:31 +0200
Subject: [PATCH 030/217] test: indeed.scrape executor mapping
---
.../indeed/scrape/test_executor.py | 100 ++++++++++++++++++
1 file changed, 100 insertions(+)
create mode 100644 surfsense_backend/tests/unit/capabilities/indeed/scrape/test_executor.py
diff --git a/surfsense_backend/tests/unit/capabilities/indeed/scrape/test_executor.py b/surfsense_backend/tests/unit/capabilities/indeed/scrape/test_executor.py
new file mode 100644
index 000000000..da7c48364
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/indeed/scrape/test_executor.py
@@ -0,0 +1,100 @@
+"""``indeed.scrape`` executor: verb input → actor input mapping → typed items.
+
+Boundary mocked: the proprietary scraper (injected fake). NOT mocked: the verb's
+own payload→IndeedScrapeInput mapping and the dict→IndeedItem wrapping.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from app.capabilities.indeed.scrape.executor import build_scrape_executor
+from app.capabilities.indeed.scrape.schemas import ScrapeInput, ScrapeOutput
+from app.exceptions import ForbiddenError
+from app.proprietary.platforms.indeed_jobs import (
+ IndeedAccessBlockedError,
+ IndeedScrapeInput,
+)
+
+pytestmark = pytest.mark.unit
+
+
+class _FakeScraper:
+ """Records the actor input + limit it was called with; returns canned items."""
+
+ def __init__(self, items: list[dict]):
+ self._items = items
+ self.calls: list[tuple[IndeedScrapeInput, int | None]] = []
+
+ async def __call__(
+ self, actor_input: IndeedScrapeInput, *, limit: int | None = None
+ ) -> list[dict]:
+ self.calls.append((actor_input, limit))
+ return self._items
+
+
+async def test_maps_urls_to_start_urls_and_wraps_items():
+ scraper = _FakeScraper([{"jobKey": "abc", "title": "Data Analyst"}])
+ execute = build_scrape_executor(scrape_fn=scraper)
+
+ out = await execute(ScrapeInput(urls=["https://www.indeed.com/jobs?q=dev"]))
+
+ assert isinstance(out, ScrapeOutput)
+ assert len(out.items) == 1
+ assert out.items[0].jobKey == "abc"
+ assert out.items[0].title == "Data Analyst"
+
+ (actor_input, _limit) = scraper.calls[0]
+ assert [u.url for u in actor_input.startUrls] == [
+ "https://www.indeed.com/jobs?q=dev"
+ ]
+ assert actor_input.queries == []
+
+
+async def test_maps_search_params_and_passes_limit():
+ scraper = _FakeScraper([])
+ execute = build_scrape_executor(scrape_fn=scraper)
+
+ await execute(
+ ScrapeInput(
+ search_queries=["data analyst"],
+ country="gb",
+ location="Remote",
+ job_type="fulltime",
+ level="entry_level",
+ remote="remote",
+ from_days=7,
+ sort="date",
+ max_items=40,
+ max_items_per_query=15,
+ )
+ )
+
+ (actor_input, limit) = scraper.calls[0]
+ assert actor_input.queries == ["data analyst"]
+ assert actor_input.country == "gb"
+ assert actor_input.location == "Remote"
+ assert actor_input.jobType == "fulltime"
+ assert actor_input.level == "entry_level"
+ assert actor_input.remote == "remote"
+ assert actor_input.fromDays == 7
+ assert actor_input.sort == "date"
+ assert actor_input.maxItems == 40
+ assert actor_input.maxItemsPerQuery == 15
+ # The outer collection limit is the caller's total-item cap.
+ assert limit == 40
+
+
+async def test_access_blocked_maps_to_forbidden():
+ async def _blocked(actor_input: IndeedScrapeInput, *, limit: int | None = None):
+ raise IndeedAccessBlockedError("all IPs refused")
+
+ execute = build_scrape_executor(scrape_fn=_blocked)
+
+ with pytest.raises(ForbiddenError):
+ await execute(ScrapeInput(search_queries=["x"]))
+
+
+def test_requires_a_source():
+ with pytest.raises(ValueError, match="at least one"):
+ ScrapeInput()
From dc945aa14dfd124e2077bbbb8be34d3925f699ac Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:50:52 +0200
Subject: [PATCH 031/217] test: assert reddit.scrape billing unit
---
.../tests/unit/capabilities/reddit/test_registry.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/surfsense_backend/tests/unit/capabilities/reddit/test_registry.py b/surfsense_backend/tests/unit/capabilities/reddit/test_registry.py
index e0bca5f26..2ac839670 100644
--- a/surfsense_backend/tests/unit/capabilities/reddit/test_registry.py
+++ b/surfsense_backend/tests/unit/capabilities/reddit/test_registry.py
@@ -8,15 +8,16 @@ from app.capabilities import (
reddit, # noqa: F401 — importing the namespace registers its verbs
)
from app.capabilities.core.store import get_capability
+from app.capabilities.core.types import BillingUnit
from app.capabilities.reddit.scrape.schemas import ScrapeInput, ScrapeOutput
pytestmark = pytest.mark.unit
-def test_reddit_scrape_is_registered_and_free():
+def test_reddit_scrape_is_registered_and_billed_per_item():
cap = get_capability("reddit.scrape")
assert cap.name == "reddit.scrape"
assert cap.input_schema is ScrapeInput
assert cap.output_schema is ScrapeOutput
- assert cap.billing_unit is None
+ assert cap.billing_unit is BillingUnit.REDDIT_ITEM
From ffc0675ac288281acb77efbdc097d70c80033bf0 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:50:52 +0200
Subject: [PATCH 032/217] test: assert google_maps billing units
---
.../tests/unit/capabilities/google_maps/test_registry.py | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/surfsense_backend/tests/unit/capabilities/google_maps/test_registry.py b/surfsense_backend/tests/unit/capabilities/google_maps/test_registry.py
index c6a007645..f9f774e8f 100644
--- a/surfsense_backend/tests/unit/capabilities/google_maps/test_registry.py
+++ b/surfsense_backend/tests/unit/capabilities/google_maps/test_registry.py
@@ -8,25 +8,26 @@ from app.capabilities import (
google_maps, # noqa: F401 — importing the namespace registers its verbs
)
from app.capabilities.core.store import get_capability
+from app.capabilities.core.types import BillingUnit
from app.capabilities.google_maps.reviews.schemas import ReviewsInput, ReviewsOutput
from app.capabilities.google_maps.scrape.schemas import ScrapeInput, ScrapeOutput
pytestmark = pytest.mark.unit
-def test_google_maps_scrape_is_registered_and_free():
+def test_google_maps_scrape_is_registered_and_billed_per_place():
cap = get_capability("google_maps.scrape")
assert cap.name == "google_maps.scrape"
assert cap.input_schema is ScrapeInput
assert cap.output_schema is ScrapeOutput
- assert cap.billing_unit is None
+ assert cap.billing_unit is BillingUnit.GOOGLE_MAPS_PLACE
-def test_google_maps_reviews_is_registered_and_free():
+def test_google_maps_reviews_is_registered_and_billed_per_review():
cap = get_capability("google_maps.reviews")
assert cap.name == "google_maps.reviews"
assert cap.input_schema is ReviewsInput
assert cap.output_schema is ReviewsOutput
- assert cap.billing_unit is None
+ assert cap.billing_unit is BillingUnit.GOOGLE_MAPS_REVIEW
From 0089bb90012e2c356be498bd19be76e893b0e88e Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 14 Jul 2026 21:50:52 +0200
Subject: [PATCH 033/217] test: assert youtube billing units
---
.../tests/unit/capabilities/youtube/test_registry.py | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/surfsense_backend/tests/unit/capabilities/youtube/test_registry.py b/surfsense_backend/tests/unit/capabilities/youtube/test_registry.py
index 756b65176..5de00891f 100644
--- a/surfsense_backend/tests/unit/capabilities/youtube/test_registry.py
+++ b/surfsense_backend/tests/unit/capabilities/youtube/test_registry.py
@@ -8,25 +8,26 @@ from app.capabilities import (
youtube, # noqa: F401 — importing the namespace registers its verbs
)
from app.capabilities.core.store import get_capability
+from app.capabilities.core.types import BillingUnit
from app.capabilities.youtube.comments.schemas import CommentsInput, CommentsOutput
from app.capabilities.youtube.scrape.schemas import ScrapeInput, ScrapeOutput
pytestmark = pytest.mark.unit
-def test_youtube_scrape_is_registered_and_free():
+def test_youtube_scrape_is_registered_and_billed_per_video():
cap = get_capability("youtube.scrape")
assert cap.name == "youtube.scrape"
assert cap.input_schema is ScrapeInput
assert cap.output_schema is ScrapeOutput
- assert cap.billing_unit is None
+ assert cap.billing_unit is BillingUnit.YOUTUBE_VIDEO
-def test_youtube_comments_is_registered_and_free():
+def test_youtube_comments_is_registered_and_billed_per_comment():
cap = get_capability("youtube.comments")
assert cap.name == "youtube.comments"
assert cap.input_schema is CommentsInput
assert cap.output_schema is CommentsOutput
- assert cap.billing_unit is None
+ assert cap.billing_unit is BillingUnit.YOUTUBE_COMMENT
From a51630543e3625223510b16e964172bcffcd6230 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 15 Jul 2026 16:08:08 +0530
Subject: [PATCH 034/217] refactor: remove deprecated configuration files for
searxng
- Deleted `limiter.toml` and `settings.yml` as they are no longer needed in the current setup.
---
docker/searxng/limiter.toml | 5 ---
docker/searxng/settings.yml | 90 -------------------------------------
2 files changed, 95 deletions(-)
delete mode 100644 docker/searxng/limiter.toml
delete mode 100644 docker/searxng/settings.yml
diff --git a/docker/searxng/limiter.toml b/docker/searxng/limiter.toml
deleted file mode 100644
index dce84146f..000000000
--- a/docker/searxng/limiter.toml
+++ /dev/null
@@ -1,5 +0,0 @@
-[botdetection.ip_limit]
-link_token = false
-
-[botdetection.ip_lists]
-pass_ip = ["0.0.0.0/0"]
diff --git a/docker/searxng/settings.yml b/docker/searxng/settings.yml
deleted file mode 100644
index 0b805b6aa..000000000
--- a/docker/searxng/settings.yml
+++ /dev/null
@@ -1,90 +0,0 @@
-use_default_settings:
- engines:
- remove:
- - ahmia
- - torch
- - qwant
- - qwant news
- - qwant images
- - qwant videos
- - mojeek
- - mojeek images
- - mojeek news
-
-server:
- secret_key: "override-me-via-env"
- limiter: false
- image_proxy: false
- method: "GET"
- default_http_headers:
- X-Robots-Tag: "noindex, nofollow"
-
-search:
- formats:
- - html
- - json
- default_lang: "auto"
- autocomplete: ""
- safe_search: 0
- ban_time_on_fail: 5
- max_ban_time_on_fail: 120
- suspended_times:
- SearxEngineAccessDenied: 3600
- SearxEngineCaptcha: 3600
- SearxEngineTooManyRequests: 600
- cf_SearxEngineCaptcha: 7200
- cf_SearxEngineAccessDenied: 3600
- recaptcha_SearxEngineCaptcha: 7200
-
-ui:
- static_use_hash: true
-
-outgoing:
- request_timeout: 12.0
- max_request_timeout: 20.0
- pool_connections: 100
- pool_maxsize: 20
- enable_http2: true
- extra_proxy_timeout: 10
- retries: 1
- # Uncomment and set your residential proxy URL to route search engine requests through it.
- # Format: http://:@:/
- #
- # proxies:
- # all://:
- # - http://user:pass@proxy-host:port/
-
-engines:
- - name: google
- disabled: false
- weight: 1.2
- retry_on_http_error: [429, 503]
- - name: duckduckgo
- disabled: false
- weight: 1.1
- retry_on_http_error: [429, 503]
- - name: brave
- disabled: false
- weight: 1.0
- retry_on_http_error: [429, 503]
- - name: bing
- disabled: false
- weight: 0.9
- retry_on_http_error: [429, 503]
- - name: wikipedia
- disabled: false
- weight: 0.8
- - name: stackoverflow
- disabled: false
- weight: 0.7
- - name: yahoo
- disabled: false
- weight: 0.7
- retry_on_http_error: [429, 503]
- - name: wikidata
- disabled: false
- weight: 0.6
- - name: currency
- disabled: false
- - name: ddg definitions
- disabled: false
From 3740171a5ce7625545cb596e38cb70ce81c976d3 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 15 Jul 2026 18:12:30 +0530
Subject: [PATCH 035/217] feat(layout): add cookie persistence for playground
sidebar state management
---
.../components/layout/ui/shell/LayoutShell.tsx | 17 +++++++++++++++--
1 file changed, 15 insertions(+), 2 deletions(-)
diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
index 7a42d5a2e..8578b7dec 100644
--- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
+++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
@@ -44,6 +44,20 @@ const DocumentTabContent = dynamic(
const PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE = "surfsense_playground_sidebar_collapsed";
const PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
+function persistPlaygroundSidebarCollapsedCookie(isCollapsed: boolean) {
+ void window.cookieStore
+ ?.set({
+ name: PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE,
+ value: String(isCollapsed),
+ path: "/",
+ expires: Date.now() + PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE * 1000,
+ sameSite: "lax",
+ })
+ .catch(() => {
+ // Ignore preference persistence failures.
+ });
+}
+
function MacDesktopTitleBar({
isSidebarCollapsed,
onToggleSidebar,
@@ -242,8 +256,7 @@ export function LayoutShell({
const handlePlaygroundSidebarToggle = () => {
setIsPlaygroundSidebarCollapsed((collapsed) => {
const nextCollapsed = !collapsed;
- const secureAttribute = window.location.protocol === "https:" ? "; Secure" : "";
- document.cookie = `${PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE}=${nextCollapsed}; Path=/; Max-Age=${PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE}; SameSite=Lax${secureAttribute}`;
+ persistPlaygroundSidebarCollapsedCookie(nextCollapsed);
return nextCollapsed;
});
};
From 7889babb8e8743b7938f8eb60cef1ee9a944c345 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 15 Jul 2026 20:11:41 +0530
Subject: [PATCH 036/217] feat(sidebar): add loading skeleton for threads in
AllChatsSidebar component
---
.../layout/ui/sidebar/AllChatsSidebar.tsx | 26 ++++++++++++++++---
1 file changed, 23 insertions(+), 3 deletions(-)
diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx
index 3063a9c50..04780bf3e 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx
@@ -94,7 +94,9 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) {
const {
data: threadsData,
error: threadsError,
+ isFetching: isFetchingThreads,
isLoading: isLoadingThreads,
+ isPlaceholderData,
} = useQuery({
queryKey: ["all-threads", workspaceId],
queryFn: () => fetchThreads(Number(workspaceId)),
@@ -228,6 +230,7 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) {
}, []);
const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads;
+ const isLoadingMoreThreads = !isSearchMode && isFetchingThreads && isPlaceholderData;
const error = isSearchMode ? searchError : threadsError;
const selectedFilterLabel = showArchived ? "Archived" : "Active";
@@ -307,10 +310,12 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) {
{[75, 90, 55, 80, 65, 85].map((titleWidth) => (
-
-
+
))}
@@ -485,6 +490,21 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) {
);
})}
+ {isLoadingMoreThreads ? (
+
+ {[62, 48, 56].map((titleWidth) => (
+
+
+
+ ))}
+
+ ) : null}
) : isSearchMode ? (
From a466fdcf5d5dd54393a1f65cf85bd25210ee3c94 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 15 Jul 2026 21:12:51 +0530
Subject: [PATCH 037/217] refactor(AllChatsSidebar): replace timestamp
formatting with relative date display and adjust button styles
---
.../layout/ui/sidebar/AllChatsSidebar.tsx | 102 ++++++++----------
surfsense_web/lib/format-date.ts | 22 ++--
2 files changed, 56 insertions(+), 68 deletions(-)
diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx
index 04780bf3e..e7cdb7d5e 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx
@@ -37,14 +37,13 @@ import {
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useActivateChatThread } from "@/hooks/use-activate-chat-thread";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLongPress } from "@/hooks/use-long-press";
import { useIsMobile } from "@/hooks/use-mobile";
import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations";
import { fetchThreads, searchThreads, type ThreadListItem } from "@/lib/chat/thread-persistence";
-import { formatThreadTimestamp } from "@/lib/format-date";
+import { formatRelativeDate } from "@/lib/format-date";
import { cn } from "@/lib/utils";
interface AllChatsContentProps {
@@ -288,7 +287,7 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) {
placeholder={t("search_chats") || "Search chats..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
- className="h-12 border-0 bg-muted pl-10 pr-9 text-base shadow-none"
+ className="h-10 border-0 bg-muted pl-10 pr-9 text-base shadow-none"
/>
{searchQuery && (
{thread.title || "New Chat"}
) : (
-
-
- handleThreadClick(thread)}
- onMouseEnter={() => prefetchChatThread(thread.id)}
- onFocus={() => prefetchChatThread(thread.id)}
- disabled={isBusy}
- className={cn(
- "h-auto w-full justify-start gap-2.5 overflow-hidden px-3 py-2.5 text-left text-base font-normal",
- "group-hover/item:bg-accent group-hover/item:text-accent-foreground",
- "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
- thread.visibility === "SEARCH_SPACE" && "pr-16",
- isActive && "bg-accent text-accent-foreground",
- isBusy && "opacity-50 pointer-events-none"
- )}
- >
-
- {thread.title || "New Chat"}
-
-
-
-
-
- {t("updated") || "Updated"}: {formatThreadTimestamp(thread.updatedAt)}
-
-
-
+
handleThreadClick(thread)}
+ onMouseEnter={() => prefetchChatThread(thread.id)}
+ onFocus={() => prefetchChatThread(thread.id)}
+ disabled={isBusy}
+ className={cn(
+ "h-auto w-full justify-start gap-2.5 overflow-hidden px-3 py-2.5 text-left text-base font-normal",
+ "group-hover/item:bg-accent group-hover/item:text-accent-foreground",
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
+ thread.visibility === "SEARCH_SPACE" ? "pr-44" : "pr-36",
+ isActive && "bg-accent text-accent-foreground",
+ isBusy && "opacity-50 pointer-events-none"
+ )}
+ >
+ {thread.title || "New Chat"}
+
)}
-
- {thread.visibility === "SEARCH_SPACE" ? (
-
- Shared
-
- ) : null}
+
+
+ {thread.visibility === "SEARCH_SPACE" ? (
+
+ Shared
+
+ ) : null}
+
+ {formatRelativeDate(thread.updatedAt)}
+
+
setOpenDropdownId(isOpen ? thread.id : null)}
@@ -436,11 +426,9 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) {
variant="ghost"
size="icon"
className={cn(
- "pointer-events-auto h-7 w-7 hover:bg-transparent",
+ "pointer-events-auto absolute right-0 h-7 w-7 hover:bg-transparent",
openDropdownId === thread.id && "bg-accent hover:bg-accent",
- !isMobile &&
- openDropdownId !== thread.id &&
- "opacity-0 group-hover/item:opacity-100"
+ openDropdownId !== thread.id && "opacity-0 group-hover/item:opacity-100"
)}
disabled={isBusy}
>
diff --git a/surfsense_web/lib/format-date.ts b/surfsense_web/lib/format-date.ts
index a062b08fa..06ae77c64 100644
--- a/surfsense_web/lib/format-date.ts
+++ b/surfsense_web/lib/format-date.ts
@@ -5,29 +5,29 @@ import {
isThisYear,
isToday,
isTomorrow,
- isYesterday,
} from "date-fns";
/**
* Format a date string as a human-readable relative time
* - < 1 min: "Just now"
- * - < 60 min: "15m ago"
- * - Today: "Today, 2:30 PM"
- * - Yesterday: "Yesterday, 2:30 PM"
- * - < 7 days: "3d ago"
+ * - < 60 min: "15 minutes ago"
+ * - < 24 hours: "21 hours ago"
+ * - < 7 days: "2 days ago"
+ * - Older this year: "Jan 15"
* - Older: "Jan 15, 2026"
*/
export function formatRelativeDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
- const minutesAgo = differenceInMinutes(now, date);
- const daysAgo = differenceInDays(now, date);
+ const minutesAgo = Math.max(0, differenceInMinutes(now, date));
+ const hoursAgo = Math.floor(minutesAgo / 60);
+ const daysAgo = Math.floor(hoursAgo / 24);
if (minutesAgo < 1) return "Just now";
- if (minutesAgo < 60) return `${minutesAgo}m ago`;
- if (isToday(date)) return `Today, ${format(date, "h:mm a")}`;
- if (isYesterday(date)) return `Yesterday, ${format(date, "h:mm a")}`;
- if (daysAgo < 7) return `${daysAgo}d ago`;
+ if (minutesAgo < 60) return `${minutesAgo} minute${minutesAgo === 1 ? "" : "s"} ago`;
+ if (hoursAgo < 24) return `${hoursAgo} hour${hoursAgo === 1 ? "" : "s"} ago`;
+ if (daysAgo < 7) return `${daysAgo} day${daysAgo === 1 ? "" : "s"} ago`;
+ if (isThisYear(date)) return format(date, "MMM d");
return format(date, "MMM d, yyyy");
}
From 8aaf9cadbe948c4771ba918f20256fd803e4b616 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 15 Jul 2026 21:16:38 +0530
Subject: [PATCH 038/217] feat(sidebar): add onChatsClick handler to sidebar
components for improved navigation
---
.../layout/providers/LayoutDataProvider.tsx | 4 ++++
.../components/layout/ui/shell/LayoutShell.tsx | 4 ++++
.../components/layout/ui/sidebar/MobileSidebar.tsx | 10 ++++++++++
.../components/layout/ui/sidebar/Sidebar.tsx | 14 +++++++++++++-
4 files changed, 31 insertions(+), 1 deletion(-)
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index 7fc862e49..682d2923e 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -631,6 +631,9 @@ export function LayoutDataProvider({
const isArtifactsPage = pathname?.endsWith("/artifacts") === true;
const isPlaygroundPage = pathname?.includes("/playground") === true;
const isAllChatsPage = pathname?.endsWith("/chats") === true;
+ const handleChatsClick = useCallback(() => {
+ router.push(`/dashboard/${workspaceId}/chats`);
+ }, [router, workspaceId]);
const handleViewAllChats = useCallback(() => {
router.push(
isAllChatsPage ? `/dashboard/${workspaceId}/new-chat` : `/dashboard/${workspaceId}/chats`
@@ -671,6 +674,7 @@ export function LayoutDataProvider({
onChatRename={handleChatRename}
onChatDelete={handleChatDelete}
onChatArchive={handleChatArchive}
+ onChatsClick={handleChatsClick}
onViewAllChats={handleViewAllChats}
user={{
email: user?.email || "",
diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
index 8578b7dec..44a2259ee 100644
--- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
+++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
@@ -106,6 +106,7 @@ interface LayoutShellProps {
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
+ onChatsClick?: () => void;
onViewAllChats?: () => void;
user: User;
onSettings?: () => void;
@@ -208,6 +209,7 @@ export function LayoutShell({
onChatRename,
onChatDelete,
onChatArchive,
+ onChatsClick,
onViewAllChats,
user,
onSettings,
@@ -289,6 +291,7 @@ export function LayoutShell({
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
+ onChatsClick={onChatsClick}
onViewAllChats={onViewAllChats}
isAllChatsActive={isAllChatsPage}
user={user}
@@ -388,6 +391,7 @@ export function LayoutShell({
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
+ onChatsClick={onChatsClick}
onViewAllChats={onViewAllChats}
isAllChatsActive={isAllChatsPage}
user={user}
diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
index 1d02b56f4..de1b08a6a 100644
--- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
@@ -28,6 +28,7 @@ interface MobileSidebarProps {
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
+ onChatsClick?: () => void;
onViewAllChats?: () => void;
isAllChatsActive?: boolean;
user: User;
@@ -77,6 +78,7 @@ export function MobileSidebar({
onChatRename,
onChatDelete,
onChatArchive,
+ onChatsClick,
onViewAllChats,
isAllChatsActive = false,
user,
@@ -194,6 +196,14 @@ export function MobileSidebar({
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
+ onChatsClick={
+ onChatsClick
+ ? () => {
+ onOpenChange(false);
+ onChatsClick();
+ }
+ : undefined
+ }
onViewAllChats={
onViewAllChats
? () => {
diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
index 7862ae90e..e24523448 100644
--- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
@@ -1,6 +1,6 @@
"use client";
-import { CreditCard, SquarePen, Zap } from "lucide-react";
+import { CreditCard, MessageCircleMore, SquarePen, Zap } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -62,6 +62,7 @@ interface SidebarProps {
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
+ onChatsClick?: () => void;
onViewAllChats?: () => void;
isAllChatsActive?: boolean;
user: User;
@@ -101,6 +102,7 @@ export function Sidebar({
onChatRename,
onChatDelete,
onChatArchive,
+ onChatsClick,
onViewAllChats,
isAllChatsActive = false,
user,
@@ -222,6 +224,16 @@ export function Sidebar({
onScroll={(event) => setIsSidebarNavScrolled(event.currentTarget.scrollTop > 0)}
>
+ {onChatsClick && (
+
+ )}
{automationsItem && (
Date: Wed, 15 Jul 2026 21:19:37 +0530
Subject: [PATCH 039/217] refactor(automations-loading): update skeleton
loading styles for improved alignment and consistency
---
.../components/automations-loading.tsx | 17 +++++++----------
1 file changed, 7 insertions(+), 10 deletions(-)
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx
index 1156be3f6..058e61598 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx
@@ -13,21 +13,18 @@ export function AutomationsLoadingRows() {
return (
<>
{ROW_KEYS.map((key) => (
-
-
-
-
-
-
+
+
+
-
+
-
+
-
-
+
+
))}
From 6ee95e5b2c21986dc31d3016a8c515c7da749c69 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 17:54:38 +0200
Subject: [PATCH 040/217] feat: parse indeed /viewjob detail page
---
.../platforms/indeed_jobs/parsers.py | 77 +++++++++++++++++++
1 file changed, 77 insertions(+)
diff --git a/surfsense_backend/app/proprietary/platforms/indeed_jobs/parsers.py b/surfsense_backend/app/proprietary/platforms/indeed_jobs/parsers.py
index ab2b97cf9..b61b13e6a 100644
--- a/surfsense_backend/app/proprietary/platforms/indeed_jobs/parsers.py
+++ b/surfsense_backend/app/proprietary/platforms/indeed_jobs/parsers.py
@@ -22,6 +22,9 @@ _DEFAULT_BASE = "https://www.indeed.com"
_JOBCARDS_ANCHOR = 'window.mosaic.providerData["mosaic-provider-jobcards"]='
+# A /viewjob page assigns the full posting to this global.
+_INITIAL_DATA_ANCHOR = "window._initialData"
+
# Indeed's extractedSalary.type -> our SalaryPeriod.
_SALARY_PERIODS = {
"HOURLY": "hour",
@@ -79,6 +82,26 @@ def extract_jobcards_blob(html: str) -> dict | None:
return data if isinstance(data, dict) else None
+def extract_initial_data(html: str) -> dict | None:
+ """Decode the ``window._initialData`` assignment on a /viewjob page."""
+ import json
+
+ idx = html.find(_INITIAL_DATA_ANCHOR)
+ if idx == -1:
+ return None
+ brace = html.find("{", idx + len(_INITIAL_DATA_ANCHOR))
+ if brace == -1:
+ return None
+ blob = _brace_match(html, brace)
+ if not blob:
+ return None
+ try:
+ data = json.loads(blob)
+ except ValueError:
+ return None
+ return data if isinstance(data, dict) else None
+
+
def job_results(blob: dict | None) -> list[dict[str, Any]]:
"""Return the raw job records from a decoded blob."""
if not isinstance(blob, dict):
@@ -226,3 +249,57 @@ def parse_job(raw: dict[str, Any], *, base_url: str = _DEFAULT_BASE) -> dict[str
"datePublished": _utc_from_ms(raw.get("pubDate")),
"createdAt": _utc_from_ms(raw.get("createDate")),
}
+
+
+def _detail_salary(hdr: dict[str, Any]) -> dict[str, Any] | None:
+ """Salary from the detail header's flat ``salaryMin/Max/Currency/Type`` fields."""
+ smin = hdr.get("salaryMin")
+ smax = hdr.get("salaryMax")
+ if smin is None and smax is None:
+ return None
+ return {
+ "salaryText": None,
+ "salaryMin": smin,
+ "salaryMax": smax,
+ "currency": hdr.get("salaryCurrency"),
+ "period": _SALARY_PERIODS.get(hdr.get("salaryType")),
+ "isEstimated": False,
+ }
+
+
+def parse_job_detail(html: str, *, base_url: str = _DEFAULT_BASE) -> dict[str, Any]:
+ """Map a /viewjob page to enrichment fields (empty dict if not a job page).
+
+ Returns only fields the detail page actually carries, so the caller can merge
+ it onto a listing item without clobbering known values with blanks. The full
+ description (``sanitizedJobDescription``) is the field listings never have.
+ """
+ data = extract_initial_data(html)
+ if not isinstance(data, dict):
+ return {}
+ jim = (data.get("jobInfoWrapperModel") or {}).get("jobInfoModel") or {}
+ if not isinstance(jim, dict):
+ return {}
+ hdr = jim.get("jobInfoHeaderModel")
+ hdr = hdr if isinstance(hdr, dict) else {}
+ taxo = _taxonomy(hdr)
+ desc_html = jim.get("sanitizedJobDescription")
+ desc_html = desc_html if isinstance(desc_html, str) and desc_html else None
+ remote_model = hdr.get("remoteWorkModel")
+ out: dict[str, Any] = {
+ "descriptionHtml": desc_html,
+ "descriptionText": _clean_snippet(desc_html),
+ "title": hdr.get("jobTitle"),
+ "company": hdr.get("companyName"),
+ "companyUrl": _abs_url(hdr.get("companyOverviewLink"), base_url),
+ "formattedLocation": hdr.get("formattedLocation") or data.get("jobLocation"),
+ "remoteType": remote_model.get("type")
+ if isinstance(remote_model, dict)
+ else None,
+ "jobTypes": _job_types(hdr, taxo),
+ "benefits": taxo.get("benefits", []),
+ "salary": _detail_salary(hdr),
+ }
+ if _is_remote(hdr, taxo):
+ out["isRemote"] = True
+ return {k: v for k, v in out.items() if v not in (None, [], {})}
From 51c7220321103580a615ba730b463400531d6995 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 17:54:38 +0200
Subject: [PATCH 041/217] feat: indeed job-detail and enrichment flow
---
.../platforms/indeed_jobs/scraper.py | 65 +++++++++++++++----
1 file changed, 53 insertions(+), 12 deletions(-)
diff --git a/surfsense_backend/app/proprietary/platforms/indeed_jobs/scraper.py b/surfsense_backend/app/proprietary/platforms/indeed_jobs/scraper.py
index 4f66f2ebf..0c9fcfb16 100644
--- a/surfsense_backend/app/proprietary/platforms/indeed_jobs/scraper.py
+++ b/surfsense_backend/app/proprietary/platforms/indeed_jobs/scraper.py
@@ -17,7 +17,12 @@ from typing import Any
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
from .fetch import IndeedSession, now_iso, open_session
-from .parsers import extract_jobcards_blob, job_results, parse_job
+from .parsers import (
+ extract_jobcards_blob,
+ job_results,
+ parse_job,
+ parse_job_detail,
+)
from .schemas import IndeedItem, IndeedScrapeInput
from .url_resolver import build_search_url, resolve_url
@@ -76,24 +81,25 @@ async def _paginate(
return
-def _targets(input_model: IndeedScrapeInput) -> list[tuple[str, str]]:
- """Resolve inputs to ``(url, domain)`` search/company targets.
+def _targets(input_model: IndeedScrapeInput) -> list[tuple[str, str, str]]:
+ """Resolve inputs to ``(kind, url, domain)`` targets.
- ``startUrls`` take precedence over ``queries``. Job (``/viewjob``) URLs are
- skipped here; detail enrichment is a separate flow.
+ ``startUrls`` take precedence over ``queries``. ``kind`` is ``search`` for
+ query-built and search/company URLs, or ``job`` for a ``/viewjob`` URL.
"""
if input_model.startUrls:
- out: list[tuple[str, str]] = []
+ out: list[tuple[str, str, str]] = []
for entry in input_model.startUrls:
resolved = resolve_url(entry.url)
- if resolved is None or resolved.kind == "job":
- logger.warning("[indeed] skipping unsupported URL: %s", entry.url)
+ if resolved is None:
+ logger.warning("[indeed] skipping unrecognized URL: %s", entry.url)
continue
- out.append((resolved.url, resolved.domain))
+ kind = "job" if resolved.kind == "job" else "search"
+ out.append((kind, resolved.url, resolved.domain))
return out
domain = None
- urls: list[tuple[str, str]] = []
+ urls: list[tuple[str, str, str]] = []
for query in input_model.queries:
url = build_search_url(
query,
@@ -107,16 +113,49 @@ def _targets(input_model: IndeedScrapeInput) -> list[tuple[str, str]]:
sort=input_model.sort,
)
domain = domain or urlparse(url).hostname or "www.indeed.com"
- urls.append((url, domain))
+ urls.append(("search", url, domain))
return urls
+async def _enrich(session: IndeedSession, item: dict[str, Any], base_url: str) -> None:
+ """Merge a job's /viewjob detail (full description, etc.) onto ``item`` in place.
+
+ Best-effort: a blocked or malformed detail page leaves the listing fields as-is
+ rather than failing the run.
+ """
+ job_url = item.get("jobUrl")
+ if not isinstance(job_url, str):
+ return
+ try:
+ detail = parse_job_detail(await session.fetch_html(job_url), base_url=base_url)
+ except Exception as exc:
+ logger.warning("[indeed] detail fetch failed for %s: %s", job_url, exc)
+ return
+ item.update(detail)
+
+
+async def _job_item(
+ session: IndeedSession, url: str, base_url: str
+) -> dict[str, Any] | None:
+ """Scrape a single /viewjob URL into an item from its detail page alone."""
+ detail = parse_job_detail(await session.fetch_html(url), base_url=base_url)
+ if not detail:
+ return None
+ return _emit({"jobUrl": url, "source": "indeed", **detail})
+
+
async def iter_indeed(
input_model: IndeedScrapeInput, session: IndeedSession
) -> AsyncIterator[dict[str, Any]]:
"""Stream flat job items for every target, deduped by ``jobKey`` across all."""
global_seen: set[str] = set()
- for url, domain in _targets(input_model):
+ for kind, url, domain in _targets(input_model):
+ base_url = f"https://{domain}"
+ if kind == "job":
+ item = await _job_item(session, url, base_url)
+ if item is not None:
+ yield item
+ continue
async for item in _paginate(
session, url, domain=domain, max_items=input_model.maxItemsPerQuery
):
@@ -125,6 +164,8 @@ async def iter_indeed(
if job_key in global_seen:
continue
global_seen.add(job_key)
+ if input_model.scrapeJobDetails:
+ await _enrich(session, item, base_url)
yield item
From 81f535e9d0d3fde7fc0c509e553e3fb9b0e0db98 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 17:54:38 +0200
Subject: [PATCH 042/217] feat: add scrape_job_details to indeed.scrape input
---
.../app/capabilities/indeed/scrape/schemas.py | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/surfsense_backend/app/capabilities/indeed/scrape/schemas.py b/surfsense_backend/app/capabilities/indeed/scrape/schemas.py
index a02f6f13b..02e29100d 100644
--- a/surfsense_backend/app/capabilities/indeed/scrape/schemas.py
+++ b/surfsense_backend/app/capabilities/indeed/scrape/schemas.py
@@ -75,6 +75,13 @@ class ScrapeInput(BaseModel):
default="relevance",
description="Result ordering: relevance or date.",
)
+ scrape_job_details: bool = Field(
+ default=False,
+ description=(
+ "Fetch each job's detail page for the full description (slower: one "
+ "extra page load per job)."
+ ),
+ )
max_items: int = Field(
default=25,
ge=1,
From ec258d8387190f75e8fd133e9596e47504d0767b Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 17:54:38 +0200
Subject: [PATCH 043/217] feat: map scrape_job_details in indeed executor
---
surfsense_backend/app/capabilities/indeed/scrape/executor.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/surfsense_backend/app/capabilities/indeed/scrape/executor.py b/surfsense_backend/app/capabilities/indeed/scrape/executor.py
index 60df11972..85983e0da 100644
--- a/surfsense_backend/app/capabilities/indeed/scrape/executor.py
+++ b/surfsense_backend/app/capabilities/indeed/scrape/executor.py
@@ -33,6 +33,7 @@ def build_scrape_executor(scrape_fn: ScrapeFn | None = None) -> Executor:
remote=payload.remote,
fromDays=payload.from_days,
sort=payload.sort,
+ scrapeJobDetails=payload.scrape_job_details,
maxItems=payload.max_items,
maxItemsPerQuery=payload.max_items_per_query,
)
From 0b73581cbe501c5f74b2f11370289ec0ebd0be76 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 17:54:38 +0200
Subject: [PATCH 044/217] test: indeed viewjob detail fixture
---
.../unit/platforms/indeed_jobs/fixtures/sample_viewjob.html | 1 +
1 file changed, 1 insertion(+)
create mode 100644 surfsense_backend/tests/unit/platforms/indeed_jobs/fixtures/sample_viewjob.html
diff --git a/surfsense_backend/tests/unit/platforms/indeed_jobs/fixtures/sample_viewjob.html b/surfsense_backend/tests/unit/platforms/indeed_jobs/fixtures/sample_viewjob.html
new file mode 100644
index 000000000..ad09bf38c
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/indeed_jobs/fixtures/sample_viewjob.html
@@ -0,0 +1 @@
+
\ No newline at end of file
From 38c98ba0e425b59508fa9e0536b84332c2a4d548 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 17:54:38 +0200
Subject: [PATCH 045/217] test: indeed detail parser
---
.../platforms/indeed_jobs/test_parsers.py | 37 +++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_parsers.py b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_parsers.py
index 7b9ca6298..38a6544b6 100644
--- a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_parsers.py
+++ b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_parsers.py
@@ -9,6 +9,7 @@ from app.proprietary.platforms.indeed_jobs.parsers import (
extract_jobcards_blob,
job_results,
parse_job,
+ parse_job_detail,
)
_FIXTURE_DIR = Path(__file__).parent / "fixtures"
@@ -137,3 +138,39 @@ def test_fixture_blob_parses_into_items():
assert item["title"]
assert item["jobUrl"].startswith("https://www.indeed.com/viewjob?jk=")
assert "salaryText" in item["salary"]
+
+
+# --- detail page (parse_job_detail) ----------------------------------------
+
+
+def test_parse_job_detail_extracts_description_and_fields():
+ html = (_FIXTURE_DIR / "sample_viewjob.html").read_text()
+ detail = parse_job_detail(html)
+ assert detail["descriptionHtml"].startswith("")
+ assert "Data Analyst" in detail["descriptionText"]
+ assert "
" not in detail["descriptionText"] # tags stripped
+ assert detail["title"]
+ assert detail["company"]
+ assert detail["formattedLocation"]
+ assert detail["jobTypes"] == ["Full-time"]
+ assert detail["benefits"] == ["401(k)", "Health insurance"]
+ assert detail["isRemote"] is True
+ assert detail["remoteType"] == "HYBRID"
+ sal = detail["salary"]
+ assert (sal["salaryMin"], sal["salaryMax"], sal["period"]) == (60000, 90000, "year")
+
+
+def test_parse_job_detail_omits_blank_fields():
+ # A page with a header but no salary/description must not emit those keys,
+ # so a merge won't clobber listing values with blanks.
+ html = (
+ "'
+ )
+ detail = parse_job_detail(html)
+ assert detail == {"title": "Analyst"}
+
+
+def test_parse_job_detail_not_a_job_page_returns_empty():
+ assert parse_job_detail("just a moment...") == {}
From d2af2402b4a3453dfdaaa1f2ddbeae96b72ba8cc Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 17:54:38 +0200
Subject: [PATCH 046/217] test: indeed detail and enrichment flow
---
.../platforms/indeed_jobs/test_scraper.py | 45 +++++++++++++++++--
1 file changed, 41 insertions(+), 4 deletions(-)
diff --git a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py
index c56d64f75..e5ea23452 100644
--- a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py
+++ b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py
@@ -22,8 +22,21 @@ def _page_html(job_keys: list[str]) -> str:
)
+def _detail_html(job_key: str) -> str:
+ """A minimal /viewjob page carrying a full description for ``job_key``."""
+ data = {
+ "jobInfoWrapperModel": {
+ "jobInfoModel": {
+ "sanitizedJobDescription": f"Full description for {job_key}
",
+ "jobInfoHeaderModel": {"jobTitle": f"Detailed {job_key}"},
+ }
+ }
+ }
+ return f""
+
+
class _FakeSession:
- """Returns per-``start`` pages and records the URLs requested."""
+ """Returns per-``start`` search pages (or a /viewjob detail) and records URLs."""
def __init__(self, pages: dict[int, list[str]]) -> None:
self._pages = pages
@@ -31,7 +44,10 @@ class _FakeSession:
async def fetch_html(self, url: str) -> str:
self.fetched.append(url)
- start = int(parse_qs(urlparse(url).query).get("start", ["0"])[0])
+ query = parse_qs(urlparse(url).query)
+ if "/viewjob" in url:
+ return _detail_html(query.get("jk", [""])[0])
+ start = int(query.get("start", ["0"])[0])
return _page_html(self._pages.get(start, []))
@@ -81,7 +97,7 @@ async def test_global_dedupe_across_queries():
@pytest.mark.asyncio
-async def test_start_urls_skip_viewjob_and_scrape_search():
+async def test_start_urls_scrape_search_and_job_url_detail():
session = _FakeSession({0: ["k1"]})
input_model = IndeedScrapeInput(
startUrls=[
@@ -91,7 +107,28 @@ async def test_start_urls_skip_viewjob_and_scrape_search():
maxItemsPerQuery=100,
)
items = await _collect(input_model, session)
- assert [i["jobKey"] for i in items] == ["k1"]
+ assert len(items) == 2
+ search_item, job_item = items
+ assert search_item["jobKey"] == "k1"
+ # The /viewjob URL is scraped from its detail page alone.
+ assert job_item["jobUrl"].endswith("jk=abc")
+ assert job_item["title"] == "Detailed abc"
+ assert "Full description for abc" in job_item["descriptionText"]
+
+
+@pytest.mark.asyncio
+async def test_scrape_job_details_enriches_listing_items():
+ session = _FakeSession({0: ["k1", "k2"]})
+ items = await _collect(
+ IndeedScrapeInput(queries=["dev"], maxItemsPerQuery=100, scrapeJobDetails=True),
+ session,
+ )
+ assert [i["jobKey"] for i in items] == ["k1", "k2"]
+ for it in items:
+ assert it["descriptionHtml"].startswith("Full description for")
+ assert "Full description for" in it["descriptionText"]
+ # One extra /viewjob load per listing item.
+ assert sum("/viewjob" in u for u in session.fetched) == 2
@pytest.mark.asyncio
From 2ade6bb90318c4cd04df9ec2a07a0e88e1d6afbf Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 18:46:55 +0200
Subject: [PATCH 047/217] feat: indeed subagent package
---
.../chat/multi_agent_chat/subagents/builtins/indeed/__init__.py | 1 +
1 file changed, 1 insertion(+)
create mode 100644 surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/__init__.py
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/__init__.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/__init__.py
new file mode 100644
index 000000000..ddd8a9cd3
--- /dev/null
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/__init__.py
@@ -0,0 +1 @@
+"""``indeed`` builtin subagent: structured Indeed job postings."""
From 7cbd8345f9cb82f4daf6972736512515ee72b61e Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 18:46:55 +0200
Subject: [PATCH 048/217] feat: indeed subagent tools package
---
.../multi_agent_chat/subagents/builtins/indeed/tools/__init__.py | 0
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/tools/__init__.py
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/tools/__init__.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/tools/__init__.py
new file mode 100644
index 000000000..e69de29bb
From ada83efedf06e6458a9db36b07ddda8638163225 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 18:46:55 +0200
Subject: [PATCH 049/217] feat: wire indeed.scrape into subagent tools
---
.../subagents/builtins/indeed/tools/index.py | 27 +++++++++++++++++++
1 file changed, 27 insertions(+)
create mode 100644 surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/tools/index.py
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/tools/index.py
new file mode 100644
index 000000000..523579471
--- /dev/null
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/tools/index.py
@@ -0,0 +1,27 @@
+"""``indeed`` sub-agent tools: the Indeed scrape capability verb."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.tools import BaseTool
+
+from app.agents.chat.multi_agent_chat.shared.permissions import Ruleset
+from app.capabilities.core.access.agent import build_capability_tools
+from app.capabilities.indeed.scrape.definition import INDEED_SCRAPE
+
+NAME = "indeed"
+
+RULESET = Ruleset(origin=NAME, rules=[])
+
+_CI_VERBS = [INDEED_SCRAPE]
+
+
+def load_tools(
+ *, dependencies: dict[str, Any] | None = None, **kwargs: Any
+) -> list[BaseTool]:
+ d = {**(dependencies or {}), **kwargs}
+ return build_capability_tools(
+ workspace_id=d.get("workspace_id"),
+ capabilities=_CI_VERBS,
+ )
From 723eb8c6cb17e096d14ae9c68f06bd599d412237 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 18:46:55 +0200
Subject: [PATCH 050/217] feat: indeed subagent routing description
---
.../multi_agent_chat/subagents/builtins/indeed/description.md | 2 ++
1 file changed, 2 insertions(+)
create mode 100644 surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/description.md
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/description.md b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/description.md
new file mode 100644
index 000000000..221c54fe9
--- /dev/null
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/description.md
@@ -0,0 +1,2 @@
+Indeed jobs specialist: pulls structured job postings — title, company, location, salary (range, currency, period), job types, benefits, remote/hybrid flag, posting age, apply URL, and the full job description. Discovers jobs by search query (with country, location, radius, job type, experience level, remote/hybrid, and date-posted filters), or scrapes a known Indeed search, company jobs, or single job URL as-is. Optionally fetches each job's detail page for the full description.
+Use whenever the task is to find or gather job listings from Indeed — openings for a role, hiring at a company, salaries for a title in a location, or remote roles in a field. Triggers include "find jobs for X", "who is hiring X", "data analyst jobs in Y", "remote X roles", and scraping a specific Indeed URL. Not for general web pages (use the web crawling specialist), Google results (use the Google Search specialist), or other job boards.
From e0cc816f673477e1ad5fc6a0825341626f33a47d Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 18:46:55 +0200
Subject: [PATCH 051/217] feat: indeed subagent system prompt
---
.../builtins/indeed/system_prompt.md | 66 +++++++++++++++++++
1 file changed, 66 insertions(+)
create mode 100644 surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/system_prompt.md
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/system_prompt.md b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/system_prompt.md
new file mode 100644
index 000000000..b034b700a
--- /dev/null
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/system_prompt.md
@@ -0,0 +1,66 @@
+You are the SurfSense Indeed sub-agent.
+You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis.
+
+
+Answer the delegated question from live Indeed job data gathered with your verb, comparing against earlier results already in this conversation when the task calls for it.
+
+
+
+- `indeed_scrape`
+- `read_run` / `search_run` (free readers for stored scrape output)
+
+
+
+- Finding jobs for a role: call `indeed_scrape` with `search_queries`; narrow with `location`, `country`, `job_type`, `level`, `remote`, `radius`, and `from_days`.
+- Scraping a specific Indeed URL: pass a search, company jobs, or single job URL in `urls`.
+- Full descriptions: set `scrape_job_details=true` to fetch each job's detail page (slower: one extra load per job). Leave it false when the listing snippet is enough.
+- Controlling volume: use `max_items` for the total cap and `max_items_per_query` per search.
+- Requested counts: `max_items` defaults to only 25 — when the task asks for N jobs, set `max_items` and `max_items_per_query` above N (with headroom for off-topic hits). A call that caps below the target can never satisfy it.
+- Under-delivery: if the first call returns fewer on-topic results than requested, broaden it yourself — more query phrasings, wider `radius`, drop restrictive filters, larger `from_days` — before settling. Return `status=partial` only after the broadened attempt, never after a single narrow call.
+- Batch multiple search terms into one call rather than many single-term calls.
+
+- Comparison requests: pull the current results, compare against prior values already in this conversation's earlier tool results, and report concrete deltas (added, removed, salary/rank changes).
+
+
+
+- Use only tools in ``.
+- Report only results present in the tool output. Never invent titles, companies, salaries, locations, or description text.
+
+
+
+- Do not read arbitrary web pages — that belongs to the web crawling specialist.
+- Do not generate deliverables or perform connector mutations; return findings for the supervisor to act on.
+- Google results belong to the Google Search specialist; other job boards are out of scope.
+
+
+
+- Report uncertainty explicitly when evidence is incomplete or conflicting.
+- Never present unverified claims as facts.
+
+
+
+- Underspecified request — no usable query or URL — return `status=blocked` with the missing fields.
+- Tool failure: return `status=error` with a concise recovery `next_step`.
+- No useful evidence: return `status=blocked` with a narrower query or the scope you still need.
+
+
+
+Return **only** one JSON object (no markdown/prose):
+{
+ "status": "success" | "partial" | "blocked" | "error",
+ "action_summary": string,
+ "evidence": {
+ "findings": string[],
+ "sources": string[],
+ "confidence": "high" | "medium" | "low"
+ },
+ "next_step": string | null,
+ "missing_fields": string[] | null,
+ "assumptions": string[] | null
+}
+
+Route-specific rules:
+- `evidence.findings`: one entry per distinct job or delta — a single sentence each; do not paste raw payloads. Max 10 entries, unless the delegated task asks for N items: then return up to N (each backed by a real scraped result, never padded).
+- `evidence.sources`: one Indeed job URL per finding when applicable, same cap as findings. List each URL once.
+
+
From e193867e7724f372b4f87a8aa7577e77a2f3cbae Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 18:46:55 +0200
Subject: [PATCH 052/217] feat: indeed subagent builder
---
.../subagents/builtins/indeed/agent.py | 43 +++++++++++++++++++
1 file changed, 43 insertions(+)
create mode 100644 surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/agent.py
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/agent.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/agent.py
new file mode 100644
index 000000000..6da835dbd
--- /dev/null
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/agent.py
@@ -0,0 +1,43 @@
+"""``indeed`` route: ``SurfSenseSubagentSpec`` builder for deepagents."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.language_models import BaseChatModel
+from langchain_core.tools import BaseTool
+
+from app.agents.chat.multi_agent_chat.subagents.shared.md_file_reader import (
+ read_md_file,
+)
+from app.agents.chat.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
+from app.agents.chat.multi_agent_chat.subagents.shared.subagent_builder import (
+ pack_subagent,
+)
+
+from .tools.index import NAME, RULESET, load_tools
+
+
+def build_subagent(
+ *,
+ dependencies: dict[str, Any],
+ model: BaseChatModel | None = None,
+ middleware_stack: dict[str, Any] | None = None,
+ mcp_tools: list[BaseTool] | None = None,
+) -> SurfSenseSubagentSpec:
+ tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
+ description = (
+ read_md_file(__package__, "description").strip()
+ or "Pulls structured job postings from Indeed search and company pages."
+ )
+ system_prompt = read_md_file(__package__, "system_prompt").strip()
+ return pack_subagent(
+ name=NAME,
+ description=description,
+ system_prompt=system_prompt,
+ tools=tools,
+ ruleset=RULESET,
+ dependencies=dependencies,
+ model=model,
+ middleware_stack=middleware_stack,
+ )
From 238d2767c5e4392a0cd4e51424e7e3bfe4b957d8 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 18:46:55 +0200
Subject: [PATCH 053/217] feat: register indeed subagent in registry
---
.../app/agents/chat/multi_agent_chat/subagents/registry.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/registry.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/registry.py
index de37edfb9..bc368facd 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/registry.py
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/registry.py
@@ -21,6 +21,9 @@ from app.agents.chat.multi_agent_chat.subagents.builtins.google_maps.agent impor
from app.agents.chat.multi_agent_chat.subagents.builtins.google_search.agent import (
build_subagent as build_google_search_subagent,
)
+from app.agents.chat.multi_agent_chat.subagents.builtins.indeed.agent import (
+ build_subagent as build_indeed_subagent,
+)
from app.agents.chat.multi_agent_chat.subagents.builtins.instagram.agent import (
build_subagent as build_instagram_subagent,
)
@@ -85,6 +88,7 @@ SUBAGENT_BUILDERS_BY_NAME: dict[str, SubagentBuilder] = {
"google_drive": build_google_drive_subagent,
"google_maps": build_google_maps_subagent,
"google_search": build_google_search_subagent,
+ "indeed": build_indeed_subagent,
"instagram": build_instagram_subagent,
"knowledge_base": build_knowledge_base_subagent,
"mcp_discovery": build_mcp_discovery_subagent,
From 3a9b0405c3588d18d7738821d229572a19bd8a01 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 18:46:55 +0200
Subject: [PATCH 054/217] feat: add indeed to required-connector map
---
surfsense_backend/app/agents/chat/multi_agent_chat/constants.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/constants.py b/surfsense_backend/app/agents/chat/multi_agent_chat/constants.py
index 6bbd808fb..f0066484a 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/constants.py
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/constants.py
@@ -35,6 +35,7 @@ SUBAGENT_TO_REQUIRED_CONNECTOR_MAP: dict[str, frozenset[str]] = {
"youtube": frozenset(),
"google_maps": frozenset(),
"google_search": frozenset(),
+ "indeed": frozenset(),
"reddit": frozenset(),
"instagram": frozenset(),
"tiktok": frozenset(),
From 9e379602434344ef1513f4fe339d9ebdd4bb4319 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 18:46:55 +0200
Subject: [PATCH 055/217] test: pin indeed in subagent roster
---
.../unit/agents/multi_agent_chat/test_subagent_composition.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/test_subagent_composition.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/test_subagent_composition.py
index ee889cd17..cf5982780 100644
--- a/surfsense_backend/tests/unit/agents/multi_agent_chat/test_subagent_composition.py
+++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/test_subagent_composition.py
@@ -32,6 +32,7 @@ _EXPECTED_SUBAGENTS = frozenset(
"google_drive",
"google_maps",
"google_search",
+ "indeed",
"instagram",
"knowledge_base",
"mcp_discovery",
From 457c073c44a5d2b129abe0026198f88d4122d43e Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 18:54:49 +0200
Subject: [PATCH 056/217] feat: add indeed MCP scraper tool
---
.../features/scrapers/platforms/indeed.py | 135 ++++++++++++++++++
1 file changed, 135 insertions(+)
create mode 100644 surfsense_mcp/mcp_server/features/scrapers/platforms/indeed.py
diff --git a/surfsense_mcp/mcp_server/features/scrapers/platforms/indeed.py b/surfsense_mcp/mcp_server/features/scrapers/platforms/indeed.py
new file mode 100644
index 000000000..6ea0007f9
--- /dev/null
+++ b/surfsense_mcp/mcp_server/features/scrapers/platforms/indeed.py
@@ -0,0 +1,135 @@
+"""Indeed scraper tool."""
+
+from __future__ import annotations
+
+from typing import Annotated, Literal
+
+from mcp.server.fastmcp import FastMCP
+from pydantic import Field
+
+from ....core.client import SurfSenseClient
+from ....core.rendering import ResponseFormatParam
+from ....core.workspace_context import WorkspaceContext, WorkspaceParam
+from ..annotations import SCRAPE
+from ..capability import run_scraper
+
+IndeedSort = Literal["relevance", "date"]
+IndeedJobType = Literal[
+ "fulltime",
+ "parttime",
+ "contract",
+ "internship",
+ "temporary",
+ "permanent",
+ "seasonal",
+ "freelance",
+]
+IndeedLevel = Literal["entry_level", "mid_level", "senior_level"]
+IndeedRemote = Literal["remote", "hybrid"]
+
+
+def register(mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext) -> None:
+ """Register the Indeed tool."""
+
+ @mcp.tool(
+ name="surfsense_indeed_scrape",
+ title="Search or scrape Indeed jobs",
+ annotations=SCRAPE,
+ structured_output=False,
+ )
+ async def indeed_scrape(
+ urls: Annotated[
+ list[str] | None,
+ Field(
+ description="Indeed URLs: a search page "
+ "('https://www.indeed.com/jobs?q=data+analyst'), a company jobs "
+ "page ('/cmp//jobs'), or a single job ('/viewjob?jk=...'). "
+ "Provide urls OR search_queries."
+ ),
+ ] = None,
+ search_queries: Annotated[
+ list[str] | None,
+ Field(
+ description="Job search terms, e.g. ['data analyst', 'ml engineer']. "
+ "Provide search_queries OR urls."
+ ),
+ ] = None,
+ country: Annotated[
+ str,
+ Field(description="Country code selecting the Indeed domain, e.g. 'us', 'gb'."),
+ ] = "us",
+ location: Annotated[
+ str | None,
+ Field(description="Where to search, e.g. 'Remote', 'New York, NY'."),
+ ] = None,
+ radius: Annotated[
+ int | None,
+ Field(description="Search radius in miles/km around location."),
+ ] = None,
+ job_type: Annotated[
+ IndeedJobType | None,
+ Field(description="Employment type filter."),
+ ] = None,
+ level: Annotated[
+ IndeedLevel | None,
+ Field(description="Experience level filter."),
+ ] = None,
+ remote: Annotated[
+ IndeedRemote | None,
+ Field(description="Work model filter: remote or hybrid."),
+ ] = None,
+ from_days: Annotated[
+ int | None,
+ Field(description="Only return jobs posted within the last N days."),
+ ] = None,
+ sort: Annotated[
+ IndeedSort, Field(description="Result ordering: relevance or date.")
+ ] = "relevance",
+ scrape_job_details: Annotated[
+ bool,
+ Field(
+ description="True fetches each job's detail page for the full "
+ "description (slower); False returns the listing snippet only."
+ ),
+ ] = False,
+ max_items: Annotated[
+ int, Field(ge=1, description="Maximum jobs to return in total.")
+ ] = 25,
+ max_items_per_query: Annotated[
+ int, Field(ge=0, description="Max jobs per search/company target.")
+ ] = 25,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Search or scrape public Indeed job postings.
+
+ Use this for ANY Indeed job research — openings for a role, who is hiring
+ at a company, salaries for a title in a location, or remote roles —
+ instead of a generic web search. Returns jobs with title, company,
+ location, salary, job types, and description; set scrape_job_details for
+ the full description per job.
+ Example: search_queries=['data analyst'], location='Remote', max_items=30.
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="indeed",
+ verb="scrape",
+ payload={
+ "urls": urls,
+ "search_queries": search_queries,
+ "country": country,
+ "location": location,
+ "radius": radius,
+ "job_type": job_type,
+ "level": level,
+ "remote": remote,
+ "from_days": from_days,
+ "sort": sort,
+ "scrape_job_details": scrape_job_details,
+ "max_items": max_items,
+ "max_items_per_query": max_items_per_query,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
From b89fe1a4f08f5069108ca4e065f64c051f3b596b Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 18:54:49 +0200
Subject: [PATCH 057/217] feat: register indeed MCP tool
---
surfsense_mcp/mcp_server/features/scrapers/__init__.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/surfsense_mcp/mcp_server/features/scrapers/__init__.py b/surfsense_mcp/mcp_server/features/scrapers/__init__.py
index 9aabbe0e5..e4823ca5c 100644
--- a/surfsense_mcp/mcp_server/features/scrapers/__init__.py
+++ b/surfsense_mcp/mcp_server/features/scrapers/__init__.py
@@ -1,7 +1,7 @@
"""Scraper tools: one MCP surface per SurfSense platform capability.
-Web crawl, Google Search, Reddit, YouTube, and Google Maps each get a tool that
-maps a natural-language request to the workspace's scraper. Two run-history tools
+Web crawl, Google Search, Reddit, YouTube, Google Maps, and Indeed each get a
+tool that maps a natural-language request to the workspace's scraper. Two run-history tools
list and fetch past runs, so a large result truncated inline can be retrieved in
full later. Each platform lives in its own module under platforms/.
"""
@@ -16,6 +16,7 @@ from . import run_history
from .platforms import (
google_maps,
google_search,
+ indeed,
instagram,
reddit,
tiktok,
@@ -31,6 +32,7 @@ _REGISTRARS = (
instagram,
tiktok,
google_maps,
+ indeed,
run_history,
)
From e88ff8cef7a22e96aba153eea88b4f8886838f83 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 18:54:49 +0200
Subject: [PATCH 058/217] test: expect indeed MCP tool in selfcheck
---
surfsense_mcp/mcp_server/selfcheck.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/surfsense_mcp/mcp_server/selfcheck.py b/surfsense_mcp/mcp_server/selfcheck.py
index bb61151bc..b9f21c638 100644
--- a/surfsense_mcp/mcp_server/selfcheck.py
+++ b/surfsense_mcp/mcp_server/selfcheck.py
@@ -31,6 +31,7 @@ EXPECTED_TOOLS = {
"surfsense_google_maps_reviews",
"surfsense_instagram_scrape",
"surfsense_instagram_details",
+ "surfsense_indeed_scrape",
"surfsense_list_scraper_runs",
"surfsense_get_scraper_run",
# knowledge-base management
From 65c757fbc60d8e8d77ce24158a59922e762aff64 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 18:54:49 +0200
Subject: [PATCH 059/217] feat: mention indeed in MCP server instructions
---
surfsense_mcp/mcp_server/server.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/surfsense_mcp/mcp_server/server.py b/surfsense_mcp/mcp_server/server.py
index f801aeb15..ef5f15c8a 100644
--- a/surfsense_mcp/mcp_server/server.py
+++ b/surfsense_mcp/mcp_server/server.py
@@ -38,7 +38,8 @@ def build_server(settings: Settings) -> tuple[FastMCP, SurfSenseClient]:
"task involves Reddit (posts, comments, finding subreddits or "
"communities), YouTube (videos, transcripts, comments), Instagram "
"(posts, reels, profile details), TikTok (videos by hashtag, "
- "search, or URL), Google Maps (places, reviews), Google Search "
+ "search, or URL), Google Maps (places, reviews), Indeed (job "
+ "postings by role, company, or location), Google Search "
"results, or reading "
"specific web pages. Scraper results are persisted as runs; if an "
"inline result is truncated, fetch it in full with "
From 5b0982f80b19a317117d5500724d2b27fc63a45f Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 18:57:15 +0200
Subject: [PATCH 060/217] docs: add indeed native connector page and listings
---
.../content/docs/connectors/index.mdx | 2 +-
.../content/docs/connectors/native/indeed.mdx | 51 +++++++++++++++++++
.../content/docs/connectors/native/index.mdx | 7 ++-
.../content/docs/connectors/native/meta.json | 1 +
.../content/docs/how-to/mcp-server.mdx | 8 +--
5 files changed, 63 insertions(+), 6 deletions(-)
create mode 100644 surfsense_web/content/docs/connectors/native/indeed.mdx
diff --git a/surfsense_web/content/docs/connectors/index.mdx b/surfsense_web/content/docs/connectors/index.mdx
index f16cf79b9..5bf7d33c1 100644
--- a/surfsense_web/content/docs/connectors/index.mdx
+++ b/surfsense_web/content/docs/connectors/index.mdx
@@ -12,7 +12,7 @@ Connectors bring data into SurfSense — either as searchable knowledge or as li
}
title="Native Connectors"
- description="SurfSense's built-in scraper APIs: Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search, and Web Crawl — usable in chat, the API Playground, or via REST"
+ description="SurfSense's built-in scraper APIs: Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search, Indeed, and Web Crawl — usable in chat, the API Playground, or via REST"
href="/docs/connectors/native"
/>
/jobs`), or a single job (`/viewjob?jk=`) (max 20 sources per call, combined with queries) |
+| `search_queries` | — | Job search terms, e.g. `["data analyst"]` |
+| `country` | `us` | Country code selecting the Indeed domain, e.g. `us`, `gb`, `de` |
+| `location` | — | Where to search, e.g. `Remote`, `New York, NY` |
+| `radius` | — | Search radius in miles/km around `location` |
+| `job_type` | — | `fulltime`, `parttime`, `contract`, `internship`, `temporary`, `permanent`, `seasonal`, or `freelance` |
+| `level` | — | `entry_level`, `mid_level`, or `senior_level` |
+| `remote` | — | `remote` or `hybrid` |
+| `from_days` | — | Only return jobs posted within the last N days |
+| `sort` | `relevance` | `relevance` or `date` |
+| `scrape_job_details` | `false` | Fetch each job's detail page for the full description (slower: one extra page load per job) |
+| `max_items` | `25` | Max total jobs returned across all sources (hard cap 100) |
+| `max_items_per_query` | `25` | Max jobs per search/company target |
+
+## Example
+
+```bash
+curl -X POST "$BASE_URL/api/v1/workspaces/1/scrapers/indeed/scrape" \
+ -H "Authorization: Bearer $SURFSENSE_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "search_queries": ["data analyst"],
+ "location": "Remote",
+ "remote": "remote",
+ "sort": "date",
+ "max_items": 30
+ }'
+```
+
+The response is `{ "items": [...] }` — one item per job posting. Billing is per returned job.
+
+For the full input and output JSON schemas and generated code snippets in your language, open **API Playground → Indeed → Scrape** in your workspace.
diff --git a/surfsense_web/content/docs/connectors/native/index.mdx b/surfsense_web/content/docs/connectors/native/index.mdx
index 12e1a7be3..fff814b7f 100644
--- a/surfsense_web/content/docs/connectors/native/index.mdx
+++ b/surfsense_web/content/docs/connectors/native/index.mdx
@@ -1,6 +1,6 @@
---
title: Native Connectors
-description: SurfSense's built-in scraper APIs for Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search, and the web
+description: SurfSense's built-in scraper APIs for Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search, Indeed, and the web
---
import { Card, Cards } from 'fumadocs-ui/components/card';
@@ -38,6 +38,11 @@ Native connectors are SurfSense's own scraper APIs — built into the platform,
description="Structured SERPs: organic results, people-also-ask, AI overviews"
href="/docs/connectors/native/google-search"
/>
+
@@ -257,14 +257,14 @@ For self-host (stdio), all settings are environment variables passed by the clie
- **401 errors** — the API key is wrong or expired; create a new one.
- **403 errors** — API access is disabled for the workspace; toggle **API key access** on under **API Playground → API Keys**.
- **"Could not reach SurfSense"** — the backend isn't running or `SURFSENSE_BASE_URL` is wrong.
-- **Server won't start** — run `uv run python -m mcp_server.selfcheck` inside `surfsense_mcp`; it verifies all 24 tools register without needing a backend.
+- **Server won't start** — run `uv run python -m mcp_server.selfcheck` inside `surfsense_mcp`; it verifies all 25 tools register without needing a backend.
## Tools reference
| Group | Tools |
|-------|-------|
| Workspaces | `surfsense_list_workspaces`, `surfsense_select_workspace` |
-| Scrapers | `surfsense_reddit_scrape`, `surfsense_youtube_scrape`, `surfsense_youtube_comments`, `surfsense_instagram_scrape`, `surfsense_instagram_details`, `surfsense_tiktok_scrape`, `surfsense_tiktok_comments`, `surfsense_tiktok_user_search`, `surfsense_tiktok_trending`, `surfsense_google_maps_scrape`, `surfsense_google_maps_reviews`, `surfsense_google_search`, `surfsense_web_crawl`, `surfsense_list_scraper_runs`, `surfsense_get_scraper_run` |
+| Scrapers | `surfsense_reddit_scrape`, `surfsense_youtube_scrape`, `surfsense_youtube_comments`, `surfsense_instagram_scrape`, `surfsense_instagram_details`, `surfsense_tiktok_scrape`, `surfsense_tiktok_comments`, `surfsense_tiktok_user_search`, `surfsense_tiktok_trending`, `surfsense_google_maps_scrape`, `surfsense_google_maps_reviews`, `surfsense_google_search`, `surfsense_indeed_scrape`, `surfsense_web_crawl`, `surfsense_list_scraper_runs`, `surfsense_get_scraper_run` |
| Knowledge base | `surfsense_search_knowledge_base`, `surfsense_list_documents`, `surfsense_get_document`, `surfsense_add_document`, `surfsense_upload_file`, `surfsense_update_document`, `surfsense_delete_document` |
Usage is billed exactly like the REST API — scraper tools are metered per returned item, and every call is recorded under **API Playground → Runs**.
From e19c876fbc8e984e6cd18348c127b6d8c803c98b Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 19:03:51 +0200
Subject: [PATCH 061/217] feat: wire indeed connector into env, readmes,
playground and marketing
---
README.es.md | 5 +-
README.hi.md | 5 +-
README.md | 5 +-
README.pt-BR.md | 5 +-
README.zh-CN.md | 5 +-
docker/.env.example | 1 +
surfsense_backend/.env.example | 1 +
surfsense_web/lib/auth-utils.ts | 1 +
.../lib/connectors-marketing/google-maps.tsx | 1 +
.../connectors-marketing/google-search.tsx | 1 +
.../lib/connectors-marketing/indeed.tsx | 327 ++++++++++++++++++
.../lib/connectors-marketing/index.ts | 2 +
.../lib/connectors-marketing/instagram.tsx | 1 +
.../lib/connectors-marketing/reddit.tsx | 1 +
.../lib/connectors-marketing/tiktok.tsx | 1 +
.../lib/connectors-marketing/web-crawl.tsx | 1 +
.../lib/connectors-marketing/youtube.tsx | 1 +
surfsense_web/lib/playground/catalog.ts | 7 +
.../lib/playground/platform-icons.tsx | 1 +
surfsense_web/public/connectors/indeed.svg | 5 +
20 files changed, 367 insertions(+), 10 deletions(-)
create mode 100644 surfsense_web/lib/connectors-marketing/indeed.tsx
create mode 100644 surfsense_web/public/connectors/indeed.svg
diff --git a/README.es.md b/README.es.md
index 08231b5ab..3d38b8334 100644
--- a/README.es.md
+++ b/README.es.md
@@ -22,7 +22,7 @@
# SurfSense: NotebookLM para investigación de inteligencia competitiva
-SurfSense es la **plataforma de inteligencia competitiva de código abierto para agentes de IA**, como NotebookLM pero con conectores de scraping en vivo. Tus agentes monitorean a la competencia, siguen los rankings y escuchan a tu mercado con datos en vivo de **Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search y la web abierta**, a través de una única **API REST** o un **servidor MCP**. Agentes programados y activados por eventos convierten lo que encuentran en informes y alertas, y una base de conocimiento integrada mantiene cada hallazgo disponible para búsqueda con citas.
+SurfSense es la **plataforma de inteligencia competitiva de código abierto para agentes de IA**, como NotebookLM pero con conectores de scraping en vivo. Tus agentes monitorean a la competencia, siguen los rankings y escuchan a tu mercado con datos en vivo de **Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search, Indeed y la web abierta**, a través de una única **API REST** o un **servidor MCP**. Agentes programados y activados por eventos convierten lo que encuentran en informes y alertas, y una base de conocimiento integrada mantiene cada hallazgo disponible para búsqueda con citas.
> [!NOTE]
> **📢 Una nota para nuestros usuarios de la alternativa a NotebookLM**
@@ -107,6 +107,7 @@ Las automatizaciones ejecutan turnos completos de agente según un horario o en
| **TikTok** | Videos, comentarios, hashtags y perfiles sin aprobación de la Research API | [TikTok Scraper API](https://www.surfsense.com/tiktok) |
| **Google Maps** | Lugares, calificaciones y reseñas para investigar competidores locales y prospectos | [Google Maps Scraper API](https://www.surfsense.com/google-maps) |
| **Google Search** | SERPs en vivo para seguimiento de posiciones y monitoreo de mercado | [Google Search API](https://www.surfsense.com/google-search) |
+| **Indeed** | Ofertas de empleo públicas con salarios y descripciones completas, por búsqueda o empresa | [Indeed Scraper API](https://www.surfsense.com/indeed) |
| **Web Crawl** (rastreo web) | Cualquier página de la web abierta como contenido limpio y estructurado | [Web Crawling API](https://www.surfsense.com/web-crawl) |
| **Conectores MCP externos** | Conecta cualquier servidor MCP a tus agentes, con OAuth de un clic para Notion, Slack, Jira y más | [External MCP Connectors](https://www.surfsense.com/external-mcp-connectors) |
@@ -246,7 +247,7 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
| Característica | Google NotebookLM | SurfSense |
|---------|-------------------|-----------|
-| **Datos de mercado en vivo para agentes** | No | Conectores de Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search y rastreo web vía API REST y MCP |
+| **Datos de mercado en vivo para agentes** | No | Conectores de Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search, Indeed y rastreo web vía API REST y MCP |
| **Servidor MCP** | No | Cada conector expuesto como herramienta nativa de agente, más servidores MCP propios con aplicaciones OAuth de un clic |
| **Fuentes por Notebook** | 50 (gratis) a 600 (Ultra, $249.99/mes) | Ilimitadas |
| **Número de Notebooks** | 100 (gratis) a 500 (niveles de pago) | Ilimitado |
diff --git a/README.hi.md b/README.hi.md
index eb65ff489..2a26e2146 100644
--- a/README.hi.md
+++ b/README.hi.md
@@ -22,7 +22,7 @@
# SurfSense: कॉम्पिटिटिव इंटेलिजेंस रिसर्च के लिए NotebookLM
-SurfSense **AI एजेंट्स के लिए ओपन सोर्स कॉम्पिटिटिव इंटेलिजेंस प्लेटफ़ॉर्म** है, बिलकुल NotebookLM जैसा, पर लाइव स्क्रैपिंग कनेक्टर्स के साथ। आपके एजेंट प्रतिस्पर्धियों पर नज़र रखते हैं, रैंकिंग ट्रैक करते हैं, और **Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search और ओपन वेब** से लाइव डेटा के साथ आपके बाज़ार की बात सुनते हैं, वह भी एक ही **REST API** या **MCP सर्वर** के ज़रिए। शेड्यूल्ड और इवेंट-ट्रिगर्ड एजेंट अपनी खोजों को ब्रीफ़ और अलर्ट में बदलते हैं, और एक बिल्ट-इन नॉलेज बेस हर खोज को साइटेशन के साथ खोजने योग्य बनाए रखता है।
+SurfSense **AI एजेंट्स के लिए ओपन सोर्स कॉम्पिटिटिव इंटेलिजेंस प्लेटफ़ॉर्म** है, बिलकुल NotebookLM जैसा, पर लाइव स्क्रैपिंग कनेक्टर्स के साथ। आपके एजेंट प्रतिस्पर्धियों पर नज़र रखते हैं, रैंकिंग ट्रैक करते हैं, और **Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search, Indeed और ओपन वेब** से लाइव डेटा के साथ आपके बाज़ार की बात सुनते हैं, वह भी एक ही **REST API** या **MCP सर्वर** के ज़रिए। शेड्यूल्ड और इवेंट-ट्रिगर्ड एजेंट अपनी खोजों को ब्रीफ़ और अलर्ट में बदलते हैं, और एक बिल्ट-इन नॉलेज बेस हर खोज को साइटेशन के साथ खोजने योग्य बनाए रखता है।
> [!NOTE]
> **📢 हमारे NotebookLM-विकल्प उपयोगकर्ताओं के लिए एक सूचना**
@@ -107,6 +107,7 @@ SurfSense **AI एजेंट्स के लिए ओपन सोर्स
| **TikTok** | Research API अप्रूवल के बिना वीडियो, कमेंट, हैशटैग और प्रोफ़ाइल | [TikTok Scraper API](https://www.surfsense.com/tiktok) |
| **Google Maps** | स्थानीय प्रतिस्पर्धी और लीड रिसर्च के लिए स्थान, रेटिंग और रिव्यू | [Google Maps Scraper API](https://www.surfsense.com/google-maps) |
| **Google Search** | रैंक ट्रैकिंग और मार्केट मॉनिटरिंग के लिए लाइव SERP | [Google Search API](https://www.surfsense.com/google-search) |
+| **Indeed** | सार्वजनिक नौकरी लिस्टिंग, सैलरी और पूरे विवरण के साथ, सर्च या कंपनी के अनुसार | [Indeed Scraper API](https://www.surfsense.com/indeed) |
| **Web Crawl** | ओपन वेब का कोई भी पेज साफ़-सुथरे, स्ट्रक्चर्ड कंटेंट के रूप में | [Web Crawling API](https://www.surfsense.com/web-crawl) |
| **External MCP Connectors** | कोई भी MCP सर्वर अपने एजेंट्स से जोड़ें, Notion, Slack, Jira और अन्य के लिए वन-क्लिक OAuth के साथ | [External MCP Connectors](https://www.surfsense.com/external-mcp-connectors) |
@@ -246,7 +247,7 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
| फ़ीचर | Google NotebookLM | SurfSense |
|---------|-------------------|-----------|
-| **एजेंट्स के लिए लाइव मार्केट डेटा** | नहीं | REST API और MCP के ज़रिए Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search और वेब क्रॉल कनेक्टर |
+| **एजेंट्स के लिए लाइव मार्केट डेटा** | नहीं | REST API और MCP के ज़रिए Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search, Indeed और वेब क्रॉल कनेक्टर |
| **MCP सर्वर** | नहीं | हर कनेक्टर नेटिव एजेंट टूल के रूप में उपलब्ध, साथ ही वन-क्लिक OAuth ऐप्स के साथ अपने MCP सर्वर लाने की सुविधा |
| **प्रति नोटबुक स्रोत** | 50 (Free) से 600 (Ultra, $249.99/माह) | असीमित |
| **नोटबुक की संख्या** | 100 (Free) से 500 (सशुल्क टियर) | असीमित |
diff --git a/README.md b/README.md
index ba289ada4..56af65dd1 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@
# SurfSense: NotebookLM for Competitive Intelligence Research
-SurfSense is the **open-source competitive intelligence platform for AI agents**, like NotebookLM but with live scraping connectors. Your agents monitor competitors, track rankings, and listen to your market with live data from **Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search, and the open web**, through one **REST API** or **MCP server**. Scheduled and event-triggered agents turn what they find into briefs and alerts, and a built-in knowledge base keeps every finding searchable with citations.
+SurfSense is the **open-source competitive intelligence platform for AI agents**, like NotebookLM but with live scraping connectors. Your agents monitor competitors, track rankings, and listen to your market with live data from **Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search, Indeed, and the open web**, through one **REST API** or **MCP server**. Scheduled and event-triggered agents turn what they find into briefs and alerts, and a built-in knowledge base keeps every finding searchable with citations.
> [!NOTE]
> **📢 A note for our NotebookLM-alternative users**
@@ -107,6 +107,7 @@ Automations run full agent turns on a schedule or in response to events, then wr
| **TikTok** | Videos, comments, hashtags, and profiles without Research API approval | [TikTok Scraper API](https://www.surfsense.com/tiktok) |
| **Google Maps** | Places, ratings, and reviews for local competitor and lead research | [Google Maps Scraper API](https://www.surfsense.com/google-maps) |
| **Google Search** | Live SERPs for rank tracking and market monitoring | [Google Search API](https://www.surfsense.com/google-search) |
+| **Indeed** | Public job postings with salaries and full descriptions, by search or company | [Indeed Scraper API](https://www.surfsense.com/indeed) |
| **Web Crawl** | Any page on the open web as clean, structured content | [Web Crawling API](https://www.surfsense.com/web-crawl) |
| **External MCP Connectors** | Bring any MCP server to your agents, with one-click OAuth for Notion, Slack, Jira, and more | [External MCP Connectors](https://www.surfsense.com/external-mcp-connectors) |
@@ -245,7 +246,7 @@ Still comparing us as a NotebookLM alternative? Here is the honest breakdown.
| Feature | Google NotebookLM | SurfSense |
|---------|-------------------|-----------|
-| **Live market data for agents** | No | Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search, and web crawl connectors via REST API and MCP |
+| **Live market data for agents** | No | Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search, Indeed, and web crawl connectors via REST API and MCP |
| **MCP server** | No | Every connector exposed as a native agent tool, plus bring-your-own MCP servers with one-click OAuth apps |
| **Sources per Notebook** | 50 (Free) to 600 (Ultra, $249.99/mo) | Unlimited |
| **Number of Notebooks** | 100 (Free) to 500 (paid tiers) | Unlimited |
diff --git a/README.pt-BR.md b/README.pt-BR.md
index 327e3373c..496398d19 100644
--- a/README.pt-BR.md
+++ b/README.pt-BR.md
@@ -22,7 +22,7 @@
# SurfSense: NotebookLM para Pesquisa de Inteligência Competitiva
-O SurfSense é a **plataforma open source de inteligência competitiva para agentes de IA**, como o NotebookLM, mas com conectores de scraping ao vivo. Seus agentes monitoram concorrentes, acompanham rankings e escutam o seu mercado com dados ao vivo do **Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search e da web aberta**, por meio de uma única **API REST** ou de um **servidor MCP**. Agentes agendados ou acionados por eventos transformam o que encontram em relatórios e alertas, e uma base de conhecimento integrada mantém cada descoberta pesquisável, com citações.
+O SurfSense é a **plataforma open source de inteligência competitiva para agentes de IA**, como o NotebookLM, mas com conectores de scraping ao vivo. Seus agentes monitoram concorrentes, acompanham rankings e escutam o seu mercado com dados ao vivo do **Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search, Indeed e da web aberta**, por meio de uma única **API REST** ou de um **servidor MCP**. Agentes agendados ou acionados por eventos transformam o que encontram em relatórios e alertas, e uma base de conhecimento integrada mantém cada descoberta pesquisável, com citações.
> [!NOTE]
> **📢 Um recado para nossos usuários que buscavam uma alternativa ao NotebookLM**
@@ -107,6 +107,7 @@ As automações executam turnos completos de agente de forma agendada ou em resp
| **TikTok** | Vídeos, comentários, hashtags e perfis sem aprovação da Research API | [TikTok Scraper API](https://www.surfsense.com/tiktok) |
| **Google Maps** | Estabelecimentos, avaliações e reviews para pesquisa local de concorrentes e leads | [Google Maps Scraper API](https://www.surfsense.com/google-maps) |
| **Google Search** | SERPs ao vivo para acompanhamento de rankings e monitoramento de mercado | [Google Search API](https://www.surfsense.com/google-search) |
+| **Indeed** | Vagas públicas com salários e descrições completas, por busca ou empresa | [Indeed Scraper API](https://www.surfsense.com/indeed) |
| **Web Crawl** (rastreamento web) | Qualquer página da web aberta como conteúdo limpo e estruturado | [Web Crawling API](https://www.surfsense.com/web-crawl) |
| **Conectores MCP externos** | Traga qualquer servidor MCP para seus agentes, com OAuth em um clique para Notion, Slack, Jira e outros | [External MCP Connectors](https://www.surfsense.com/external-mcp-connectors) |
@@ -246,7 +247,7 @@ Ainda nos comparando como alternativa ao NotebookLM? Aqui está o comparativo ho
| Recurso | Google NotebookLM | SurfSense |
|---------|-------------------|-----------|
-| **Dados de mercado ao vivo para agentes** | Não | Conectores de Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search e rastreamento web via API REST e MCP |
+| **Dados de mercado ao vivo para agentes** | Não | Conectores de Reddit, YouTube, Instagram, TikTok, Google Maps, Google Search, Indeed e rastreamento web via API REST e MCP |
| **Servidor MCP** | Não | Cada conector exposto como ferramenta nativa de agente, além de servidores MCP próprios com apps OAuth em um clique |
| **Fontes por Notebook** | 50 (gratuito) a 600 (Ultra, US$ 249,99/mês) | Ilimitadas |
| **Número de Notebooks** | 100 (gratuito) a 500 (planos pagos) | Ilimitado |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 53a95968b..c75e71a81 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -22,7 +22,7 @@
# SurfSense:面向竞争情报研究的 NotebookLM
-SurfSense 是**面向 AI 智能体的开源竞争情报平台**,就像 NotebookLM,但配备了实时抓取连接器。你的智能体可以通过一个 **REST API** 或 **MCP 服务器**,利用来自 **Reddit、YouTube、Instagram、TikTok、Google Maps、Google Search 和开放网络**的实时数据,监控竞争对手、追踪排名、倾听市场动态。定时和事件触发的智能体会把发现的内容转化为简报和预警,内置的知识库则让每一条发现都可搜索、可引用。
+SurfSense 是**面向 AI 智能体的开源竞争情报平台**,就像 NotebookLM,但配备了实时抓取连接器。你的智能体可以通过一个 **REST API** 或 **MCP 服务器**,利用来自 **Reddit、YouTube、Instagram、TikTok、Google Maps、Google Search、Indeed 和开放网络**的实时数据,监控竞争对手、追踪排名、倾听市场动态。定时和事件触发的智能体会把发现的内容转化为简报和预警,内置的知识库则让每一条发现都可搜索、可引用。
> [!NOTE]
> **📢 致我们的 NotebookLM 替代品用户**
@@ -107,6 +107,7 @@ SurfSense 是**面向 AI 智能体的开源竞争情报平台**,就像 Noteboo
| **TikTok** | 视频、评论、话题标签和主页,无需 Research API 审批 | [TikTok Scraper API](https://www.surfsense.com/tiktok) |
| **Google Maps** | 地点、评分和评论,用于本地竞争对手和潜在客户调研 | [Google Maps Scraper API](https://www.surfsense.com/google-maps) |
| **Google Search** | 实时搜索结果页,用于排名追踪和市场监控 | [Google Search API](https://www.surfsense.com/google-search) |
+| **Indeed** | 公开职位信息,含薪资与完整职位描述,按搜索或公司抓取 | [Indeed Scraper API](https://www.surfsense.com/indeed) |
| **Web Crawl** | 把开放网络上的任意页面转为干净、结构化的内容 | [Web Crawling API](https://www.surfsense.com/web-crawl) |
| **外部 MCP 连接器** | 将任意 MCP 服务器接入你的智能体,Notion、Slack、Jira 等支持一键 OAuth | [External MCP Connectors](https://www.surfsense.com/external-mcp-connectors) |
@@ -246,7 +247,7 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
| 功能 | Google NotebookLM | SurfSense |
|---------|-------------------|-----------|
-| **面向智能体的实时市场数据** | 无 | 通过 REST API 和 MCP 提供 Reddit、YouTube、Instagram、TikTok、Google Maps、Google Search 和网页爬取连接器 |
+| **面向智能体的实时市场数据** | 无 | 通过 REST API 和 MCP 提供 Reddit、YouTube、Instagram、TikTok、Google Maps、Google Search、Indeed 和网页爬取连接器 |
| **MCP 服务器** | 无 | 每个连接器都作为原生智能体工具暴露,还可自带 MCP 服务器并使用一键 OAuth 应用 |
| **每个笔记本的来源数** | 50 个(免费版)至 600 个(Ultra 版,249.99 美元/月) | 无限制 |
| **笔记本数量** | 100 个(免费版)至 500 个(付费档位) | 无限制 |
diff --git a/docker/.env.example b/docker/.env.example
index 2aa0806a8..7f4510320 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -457,6 +457,7 @@ SURFSENSE_ENABLE_DOOM_LOOP=true
# TIKTOK_MICROS_PER_VIDEO=3500
# TIKTOK_MICROS_PER_USER=2500
# TIKTOK_MICROS_PER_COMMENT=1500
+# INDEED_SCRAPE_MICROS_PER_JOB=3500
# Safety ceiling on per-call premium reservation, in micro-USD ($1.00 default).
# QUOTA_MAX_RESERVE_MICROS=1000000
diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example
index 3d2355460..44a3af872 100644
--- a/surfsense_backend/.env.example
+++ b/surfsense_backend/.env.example
@@ -297,6 +297,7 @@ MICROS_PER_PAGE=1000
# TIKTOK_MICROS_PER_VIDEO=3500
# TIKTOK_MICROS_PER_USER=2500
# TIKTOK_MICROS_PER_COMMENT=1500
+# INDEED_SCRAPE_MICROS_PER_JOB=3500
# Browser-listing retries when a feed is empty (profile feed is withheld from
# flagged IPs; each retry draws a fresh rotating exit IP). Set to 1 for a static IP.
# TIKTOK_LISTING_MAX_ATTEMPTS=3
diff --git a/surfsense_web/lib/auth-utils.ts b/surfsense_web/lib/auth-utils.ts
index 7d9320fb0..837c3eb44 100644
--- a/surfsense_web/lib/auth-utils.ts
+++ b/surfsense_web/lib/auth-utils.ts
@@ -44,6 +44,7 @@ const PUBLIC_ROUTE_PREFIXES = [
"/youtube",
"/google-maps",
"/google-search",
+ "/indeed",
"/web-crawl",
];
diff --git a/surfsense_web/lib/connectors-marketing/google-maps.tsx b/surfsense_web/lib/connectors-marketing/google-maps.tsx
index 03b1d247b..b09301ca6 100644
--- a/surfsense_web/lib/connectors-marketing/google-maps.tsx
+++ b/surfsense_web/lib/connectors-marketing/google-maps.tsx
@@ -306,6 +306,7 @@ export const googleMaps: ConnectorPageContent = {
{ label: "Instagram API", href: "/instagram" },
{ label: "SERP API", href: "/google-search" },
{ label: "Web Crawl API", href: "/web-crawl" },
+ { label: "Indeed API", href: "/indeed" },
{ label: "SurfSense MCP Server", href: "/mcp-server" },
{ label: "Read the docs", href: "/docs" },
],
diff --git a/surfsense_web/lib/connectors-marketing/google-search.tsx b/surfsense_web/lib/connectors-marketing/google-search.tsx
index 7f436f9b0..a7b69c2ad 100644
--- a/surfsense_web/lib/connectors-marketing/google-search.tsx
+++ b/surfsense_web/lib/connectors-marketing/google-search.tsx
@@ -261,6 +261,7 @@ export const googleSearch: ConnectorPageContent = {
{ label: "Google Maps API", href: "/google-maps" },
{ label: "Reddit API", href: "/reddit" },
{ label: "Instagram API", href: "/instagram" },
+ { label: "Indeed API", href: "/indeed" },
{ label: "SurfSense MCP Server", href: "/mcp-server" },
{ label: "Read the docs", href: "/docs" },
],
diff --git a/surfsense_web/lib/connectors-marketing/indeed.tsx b/surfsense_web/lib/connectors-marketing/indeed.tsx
new file mode 100644
index 000000000..ce717f3cc
--- /dev/null
+++ b/surfsense_web/lib/connectors-marketing/indeed.tsx
@@ -0,0 +1,327 @@
+import { IconBriefcase } from "@tabler/icons-react";
+import type { ConnectorPageContent } from "./types";
+
+export const indeed: ConnectorPageContent = {
+ slug: "indeed",
+ name: "Indeed",
+ icon: IconBriefcase,
+
+ metaTitle: "Indeed Scraper API for Jobs and Hiring Data | SurfSense",
+ metaDescription:
+ "Scrape public Indeed job postings with the SurfSense Indeed Scraper API: titles, companies, salaries, and full descriptions by search or company. No Indeed API. Start free.",
+ keywords: [
+ "indeed scraper",
+ "indeed scraper api",
+ "indeed api",
+ "indeed jobs api",
+ "scrape indeed",
+ "indeed job scraper",
+ "job posting scraper",
+ "salary data api",
+ "hiring data api",
+ "indeed mcp",
+ "labor market data",
+ "recruiting data tool",
+ ],
+
+ h1: "Indeed Scraper API for Job Postings and Hiring Data",
+ heroLede:
+ "The SurfSense Indeed API extracts public job postings, salaries, companies, and full descriptions by search query, company page, or job URL, without Indeed's official API. Give your AI agents a live feed of who is hiring, for what, at what pay, so you track the labor market as it moves.",
+
+ transcript: {
+ prompt: "Find remote data analyst roles posted this week and what they pay",
+ toolCall:
+ 'indeed.scrape({ search_queries: ["data analyst"], location: "Remote",\n remote: "remote", from_days: 7, sort: "date", max_items: 30 })',
+ rows: [
+ {
+ primary: "Senior Data Analyst · Acme Corp",
+ secondary: "Remote (US) · $120k–$145k/year · posted 2 days ago",
+ tag: "salary listed",
+ },
+ {
+ primary: "Data Analyst, Growth · Globex",
+ secondary: "Remote · $95k–$110k/year · Indeed Apply",
+ tag: "buying signal",
+ },
+ {
+ primary: "Marketing Data Analyst · Initech",
+ secondary: "Remote (US) · estimated $88k–$102k · posted today",
+ tag: "new today",
+ },
+ ],
+ resultSummary: "30 jobs · 22 with salary · surfaced in 3.4s",
+ },
+
+ extractIntro:
+ "Every call returns structured job items. Point the API at a search query, an Indeed search or company page, or a single job URL, and set scrape_job_details for the full description per job.",
+ extractFields: [
+ {
+ label: "Job",
+ description: "Title, job key, listing URL, apply URL, and whether Indeed Apply is enabled.",
+ },
+ {
+ label: "Company",
+ description: "Company name, profile URL, star rating, and review count where available.",
+ },
+ {
+ label: "Location",
+ description: "Formatted location, city, state, postal code, country, and remote or hybrid flags.",
+ },
+ {
+ label: "Salary",
+ description:
+ "Pay text, min and max bounds, currency, period, and whether the figure is an Indeed estimate.",
+ },
+ {
+ label: "Description",
+ description:
+ "Listing snippet by default; the full text and HTML description with scrape_job_details.",
+ },
+ {
+ label: "Signals",
+ description:
+ "Job types, benefits, sponsored, urgently hiring, new, and expired flags, plus post age.",
+ },
+ ],
+
+ useCasesHeading: "What teams do with the Indeed API",
+ useCases: [
+ {
+ title: "Competitor hiring intelligence",
+ description:
+ "Track what your competitors are hiring for and where. A spike in sales or ML roles is a roadmap signal months before it ships. Feed the stream to an agent that flags the moves that matter.",
+ },
+ {
+ title: "Salary and compensation benchmarking",
+ description:
+ "Pull real posted salaries for a title in a location and benchmark your own bands against the live market, instead of a survey that is a year stale.",
+ },
+ {
+ title: "Labor market and sector research",
+ description:
+ "Measure hiring demand for a role, skill, or sector over time. Turn thousands of postings into a demand index your analysts and clients can act on.",
+ },
+ {
+ title: "Recruiting and lead sourcing",
+ description:
+ "Find companies actively hiring for a role and reach them while the need is hot. Job postings are a public, timely buying signal for staffing and B2B sales.",
+ },
+ ],
+
+ comparison: {
+ heading: "An Indeed API alternative built for agents",
+ intro:
+ "Indeed retired its public Publisher jobs API and gates data behind partner programs. If you cannot get access or need clean structured jobs now, here is how SurfSense compares.",
+ columnLabel: "DIY Indeed scraping",
+ rows: [
+ {
+ feature: "Access",
+ official: "Publisher API retired; partner-gated and approval-only",
+ surfsense: "One API key; scrape public postings without an approval process",
+ },
+ {
+ feature: "Anti-bot",
+ official: "You fight Cloudflare, fingerprinting, and CAPTCHAs yourself",
+ surfsense: "Warmed, rotated sessions managed for you; no proxy plumbing",
+ },
+ {
+ feature: "Pricing",
+ official: "Proxy, browser, and maintenance costs you own",
+ surfsense: "Pay per job returned, with a free tier to start",
+ },
+ {
+ feature: "Descriptions",
+ official: "Extra page fetch and parsing you build and maintain",
+ surfsense: "Full description per job with one scrape_job_details flag",
+ },
+ {
+ feature: "Agent-ready",
+ official: "No; you build the harness yourself",
+ surfsense: "MCP server exposes indeed.scrape as a native tool",
+ },
+ ],
+ },
+
+ api: {
+ platform: "indeed",
+ verb: "scrape",
+ mcpTool: "indeed.scrape",
+ requestBody: {
+ search_queries: ["data analyst"],
+ location: "Remote",
+ remote: "remote",
+ from_days: 7,
+ sort: "date",
+ max_items: 30,
+ },
+ },
+
+ schema: {
+ requestNote:
+ "Provide at least one source: urls or search_queries. Up to 20 sources per call.",
+ request: [
+ {
+ name: "urls",
+ type: "string[]",
+ defaultValue: "[]",
+ description:
+ "Indeed URLs: a search page (/jobs?q=&l=), a company jobs page (/cmp//jobs), or a single job (/viewjob?jk=...). Max 20.",
+ },
+ {
+ name: "search_queries",
+ type: "string[]",
+ defaultValue: "[]",
+ description:
+ "Job search terms. Each returns up to max_items_per_query results, shaped by the filters below. Max 20.",
+ },
+ {
+ name: "country",
+ type: "string",
+ defaultValue: '"us"',
+ description: "Country code selecting the Indeed domain, e.g. 'us', 'gb', 'de'.",
+ },
+ {
+ name: "location",
+ type: "string",
+ description: "Where to search, e.g. 'Remote', 'New York, NY'.",
+ },
+ {
+ name: "radius",
+ type: "integer",
+ description: "Search radius in miles or km around location.",
+ },
+ {
+ name: "job_type",
+ type: "string",
+ description: "Employment type: fulltime, parttime, contract, internship, and more.",
+ },
+ {
+ name: "level",
+ type: "string",
+ description: "Experience level: entry_level, mid_level, or senior_level.",
+ },
+ {
+ name: "remote",
+ type: "string",
+ description: "Work model filter: remote or hybrid.",
+ },
+ {
+ name: "from_days",
+ type: "integer",
+ description: "Only return jobs posted within the last N days.",
+ },
+ {
+ name: "sort",
+ type: "string",
+ defaultValue: '"relevance"',
+ description: "Result ordering: relevance or date.",
+ },
+ {
+ name: "scrape_job_details",
+ type: "boolean",
+ defaultValue: "false",
+ description:
+ "Fetch each job's detail page for the full description. Slower: one extra page load per job.",
+ },
+ {
+ name: "max_items",
+ type: "integer",
+ defaultValue: "25",
+ description: "Max total jobs to return across all sources. 1 to 100.",
+ },
+ {
+ name: "max_items_per_query",
+ type: "integer",
+ defaultValue: "25",
+ description: "Max jobs to pull per search or company target.",
+ },
+ ],
+ responseNote:
+ "The response is { items: [...] } with one flat item per job. Fields Indeed omits are null. One returned job is one billable unit.",
+ response: [
+ {
+ name: "jobKey / jobUrl / applyUrl",
+ type: "string",
+ description: "Indeed job key, listing URL, and third-party apply URL.",
+ },
+ {
+ name: "title",
+ type: "string",
+ description: "The job title as posted.",
+ },
+ {
+ name: "company / companyUrl",
+ type: "string",
+ description: "Company name and its Indeed profile URL.",
+ },
+ {
+ name: "companyRating / companyReviewCount",
+ type: "number / integer",
+ description: "Employer star rating and number of reviews, where Indeed shows them.",
+ },
+ {
+ name: "formattedLocation / isRemote / remoteType",
+ type: "string / boolean",
+ description: "Location string plus remote and hybrid flags.",
+ },
+ {
+ name: "salary",
+ type: "object",
+ description:
+ "salaryText, salaryMin, salaryMax, currency, period, and isEstimated when the pay is an Indeed estimate.",
+ },
+ {
+ name: "jobTypes / benefits",
+ type: "string[]",
+ description: "Employment types and listed benefits parsed from the posting.",
+ },
+ {
+ name: "descriptionText / descriptionHtml",
+ type: "string",
+ description: "Snippet by default; the full description when scrape_job_details is set.",
+ },
+ {
+ name: "sponsored / urgentlyHiring / isNew / expired",
+ type: "boolean",
+ description: "Listing flags for ranking and filtering.",
+ },
+ {
+ name: "age / datePublished / scrapedAt",
+ type: "string",
+ description: "Relative post age, ISO publish date, and when the job was scraped.",
+ },
+ ],
+ },
+
+ faq: [
+ {
+ question: "Is scraping Indeed legal?",
+ answer:
+ "SurfSense reads only public Indeed job postings, the same listings any logged-out visitor can see. It never logs in and cannot access private or applicant data. As always, review Indeed's terms and your own compliance needs before you run at scale.",
+ },
+ {
+ question: "Does Indeed have an official jobs API?",
+ answer:
+ "Indeed retired its public Publisher jobs API and now gates job data behind partner and approval programs. SurfSense is an independent alternative: you call one API, or add the MCP server to your agent, and get structured public postings back.",
+ },
+ {
+ question: "Can I get the full job description?",
+ answer:
+ "Yes. By default each job returns the listing snippet, which is fast. Set scrape_job_details to true and SurfSense fetches each job's detail page for the full description text and HTML, at the cost of one extra page load per job.",
+ },
+ {
+ question: "What are the rate limits?",
+ answer:
+ "Each call returns up to 100 jobs across all sources, with up to 20 URLs or search queries per request. SurfSense manages the anti-bot request budget for you, so you scale reads without running proxies or a headless browser yourself.",
+ },
+ ],
+
+ related: [
+ { label: "Reddit API", href: "/reddit" },
+ { label: "YouTube API", href: "/youtube" },
+ { label: "Google Maps API", href: "/google-maps" },
+ { label: "SERP API", href: "/google-search" },
+ { label: "Web Crawl API", href: "/web-crawl" },
+ { label: "SurfSense MCP Server", href: "/mcp-server" },
+ ],
+};
diff --git a/surfsense_web/lib/connectors-marketing/index.ts b/surfsense_web/lib/connectors-marketing/index.ts
index 826a1f261..be3948398 100644
--- a/surfsense_web/lib/connectors-marketing/index.ts
+++ b/surfsense_web/lib/connectors-marketing/index.ts
@@ -1,5 +1,6 @@
import { googleMaps } from "./google-maps";
import { googleSearch } from "./google-search";
+import { indeed } from "./indeed";
import { instagram } from "./instagram";
import { reddit } from "./reddit";
import { tiktok } from "./tiktok";
@@ -17,6 +18,7 @@ const CONNECTOR_LIST: ConnectorPageContent[] = [
tiktok,
googleMaps,
googleSearch,
+ indeed,
webCrawl,
];
diff --git a/surfsense_web/lib/connectors-marketing/instagram.tsx b/surfsense_web/lib/connectors-marketing/instagram.tsx
index 5422a0c90..999be0de8 100644
--- a/surfsense_web/lib/connectors-marketing/instagram.tsx
+++ b/surfsense_web/lib/connectors-marketing/instagram.tsx
@@ -288,6 +288,7 @@ export const instagram: ConnectorPageContent = {
{ label: "Reddit API", href: "/reddit" },
{ label: "Google Maps API", href: "/google-maps" },
{ label: "SERP API", href: "/google-search" },
+ { label: "Indeed API", href: "/indeed" },
{ label: "SurfSense MCP Server", href: "/mcp-server" },
],
};
diff --git a/surfsense_web/lib/connectors-marketing/reddit.tsx b/surfsense_web/lib/connectors-marketing/reddit.tsx
index 2a930afe9..c76c9984f 100644
--- a/surfsense_web/lib/connectors-marketing/reddit.tsx
+++ b/surfsense_web/lib/connectors-marketing/reddit.tsx
@@ -315,6 +315,7 @@ export const reddit: ConnectorPageContent = {
{ label: "Google Maps API", href: "/google-maps" },
{ label: "SERP API", href: "/google-search" },
{ label: "Web Crawl API", href: "/web-crawl" },
+ { label: "Indeed API", href: "/indeed" },
{ label: "SurfSense MCP Server", href: "/mcp-server" },
],
};
diff --git a/surfsense_web/lib/connectors-marketing/tiktok.tsx b/surfsense_web/lib/connectors-marketing/tiktok.tsx
index 6063fd1ae..9a765ef0e 100644
--- a/surfsense_web/lib/connectors-marketing/tiktok.tsx
+++ b/surfsense_web/lib/connectors-marketing/tiktok.tsx
@@ -284,6 +284,7 @@ export const tiktok: ConnectorPageContent = {
{ label: "Google Maps API", href: "/google-maps" },
{ label: "SERP API", href: "/google-search" },
{ label: "Web Crawl API", href: "/web-crawl" },
+ { label: "Indeed API", href: "/indeed" },
{ label: "SurfSense MCP Server", href: "/mcp-server" },
],
};
diff --git a/surfsense_web/lib/connectors-marketing/web-crawl.tsx b/surfsense_web/lib/connectors-marketing/web-crawl.tsx
index 1cb4d5dac..1684a61e2 100644
--- a/surfsense_web/lib/connectors-marketing/web-crawl.tsx
+++ b/surfsense_web/lib/connectors-marketing/web-crawl.tsx
@@ -283,6 +283,7 @@ export const webCrawl: ConnectorPageContent = {
{ label: "Google Maps API", href: "/google-maps" },
{ label: "Reddit API", href: "/reddit" },
{ label: "Instagram API", href: "/instagram" },
+ { label: "Indeed API", href: "/indeed" },
{ label: "SurfSense MCP Server", href: "/mcp-server" },
{ label: "Read the docs", href: "/docs" },
],
diff --git a/surfsense_web/lib/connectors-marketing/youtube.tsx b/surfsense_web/lib/connectors-marketing/youtube.tsx
index fceaee940..431c78357 100644
--- a/surfsense_web/lib/connectors-marketing/youtube.tsx
+++ b/surfsense_web/lib/connectors-marketing/youtube.tsx
@@ -275,6 +275,7 @@ export const youtube: ConnectorPageContent = {
{ label: "Google Maps API", href: "/google-maps" },
{ label: "SERP API", href: "/google-search" },
{ label: "Web Crawl API", href: "/web-crawl" },
+ { label: "Indeed API", href: "/indeed" },
{ label: "SurfSense MCP Server", href: "/mcp-server" },
],
};
diff --git a/surfsense_web/lib/playground/catalog.ts b/surfsense_web/lib/playground/catalog.ts
index 456e52008..a8825e52a 100644
--- a/surfsense_web/lib/playground/catalog.ts
+++ b/surfsense_web/lib/playground/catalog.ts
@@ -2,6 +2,7 @@ import type { ComponentType } from "react";
import {
GoogleMapsIcon,
GoogleSearchIcon,
+ IndeedIcon,
InstagramIcon,
RedditIcon,
TikTokIcon,
@@ -89,6 +90,12 @@ export const PLAYGROUND_PLATFORMS: PlaygroundPlatform[] = [
icon: GoogleSearchIcon,
verbs: [{ name: "google_search.scrape", verb: "scrape", label: "Scrape" }],
},
+ {
+ id: "indeed",
+ label: "Indeed",
+ icon: IndeedIcon,
+ verbs: [{ name: "indeed.scrape", verb: "scrape", label: "Scrape" }],
+ },
{
id: "web",
label: "Web",
diff --git a/surfsense_web/lib/playground/platform-icons.tsx b/surfsense_web/lib/playground/platform-icons.tsx
index c1f61978b..5bae99d1b 100644
--- a/surfsense_web/lib/playground/platform-icons.tsx
+++ b/surfsense_web/lib/playground/platform-icons.tsx
@@ -28,4 +28,5 @@ export const InstagramIcon = brandIcon("/connectors/instagram.svg", "Instagram")
export const TikTokIcon = brandIcon("/connectors/tiktok.svg", "TikTok");
export const GoogleMapsIcon = brandIcon("/connectors/google-maps.svg", "Google Maps");
export const GoogleSearchIcon = brandIcon("/connectors/google-search.svg", "Google Search");
+export const IndeedIcon = brandIcon("/connectors/indeed.svg", "Indeed");
export const WebIcon = brandIcon("/connectors/web.svg", "Web");
diff --git a/surfsense_web/public/connectors/indeed.svg b/surfsense_web/public/connectors/indeed.svg
new file mode 100644
index 000000000..39d792bd6
--- /dev/null
+++ b/surfsense_web/public/connectors/indeed.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
From 2a3d750cbeec21a5336e396fabbe37835f202c4d Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 19:08:50 +0200
Subject: [PATCH 062/217] fix: use official indeed brand mark for connector
icon
---
surfsense_web/public/connectors/indeed.svg | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/surfsense_web/public/connectors/indeed.svg b/surfsense_web/public/connectors/indeed.svg
index 39d792bd6..9265a1934 100644
--- a/surfsense_web/public/connectors/indeed.svg
+++ b/surfsense_web/public/connectors/indeed.svg
@@ -1,5 +1 @@
-
-
-
-
-
+Indeed
From b53f00231d24b653bd969c63684c015b25e244d2 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 19:57:19 +0200
Subject: [PATCH 063/217] fix: parse indeed /viewjob detail from
window._rootProps
---
.../platforms/indeed_jobs/parsers.py | 32 ++++++++++++++++---
.../platforms/indeed_jobs/test_parsers.py | 20 ++++++++++++
2 files changed, 47 insertions(+), 5 deletions(-)
diff --git a/surfsense_backend/app/proprietary/platforms/indeed_jobs/parsers.py b/surfsense_backend/app/proprietary/platforms/indeed_jobs/parsers.py
index b61b13e6a..87824e8c7 100644
--- a/surfsense_backend/app/proprietary/platforms/indeed_jobs/parsers.py
+++ b/surfsense_backend/app/proprietary/platforms/indeed_jobs/parsers.py
@@ -22,7 +22,10 @@ _DEFAULT_BASE = "https://www.indeed.com"
_JOBCARDS_ANCHOR = 'window.mosaic.providerData["mosaic-provider-jobcards"]='
-# A /viewjob page assigns the full posting to this global.
+# A /viewjob page carries the posting model in ``window._rootProps`` (JSON,
+# under ``preloadedVJData``); older pages inlined it as ``window._initialData``.
+_ROOT_PROPS_ANCHOR = "window._rootProps"
+_ROOT_PROPS_KEY = "preloadedVJData"
_INITIAL_DATA_ANCHOR = "window._initialData"
# Indeed's extractedSalary.type -> our SalaryPeriod.
@@ -82,14 +85,14 @@ def extract_jobcards_blob(html: str) -> dict | None:
return data if isinstance(data, dict) else None
-def extract_initial_data(html: str) -> dict | None:
- """Decode the ``window._initialData`` assignment on a /viewjob page."""
+def _decode_assignment(html: str, anchor: str) -> dict | None:
+ """Decode the balanced JSON object assigned after ``anchor``, or ``None``."""
import json
- idx = html.find(_INITIAL_DATA_ANCHOR)
+ idx = html.find(anchor)
if idx == -1:
return None
- brace = html.find("{", idx + len(_INITIAL_DATA_ANCHOR))
+ brace = html.find("{", idx + len(anchor))
if brace == -1:
return None
blob = _brace_match(html, brace)
@@ -102,6 +105,25 @@ def extract_initial_data(html: str) -> dict | None:
return data if isinstance(data, dict) else None
+def extract_initial_data(html: str) -> dict | None:
+ """Return a /viewjob posting model rooted at ``jobInfoWrapperModel``.
+
+ Prefers ``window._rootProps`` (JSON) unwrapped at ``preloadedVJData``; falls
+ back to a legacy inline ``window._initialData`` blob. ``window._initialData``
+ is now a JS object literal that references other globals, so it is not JSON
+ and is skipped when the JSON parse fails.
+ """
+ root = _decode_assignment(html, _ROOT_PROPS_ANCHOR)
+ if isinstance(root, dict):
+ vj = root.get(_ROOT_PROPS_KEY)
+ if isinstance(vj, dict) and vj.get("jobInfoWrapperModel"):
+ return vj
+ legacy = _decode_assignment(html, _INITIAL_DATA_ANCHOR)
+ if isinstance(legacy, dict) and legacy.get("jobInfoWrapperModel"):
+ return legacy
+ return None
+
+
def job_results(blob: dict | None) -> list[dict[str, Any]]:
"""Return the raw job records from a decoded blob."""
if not isinstance(blob, dict):
diff --git a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_parsers.py b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_parsers.py
index 38a6544b6..43fed6948 100644
--- a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_parsers.py
+++ b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_parsers.py
@@ -160,6 +160,26 @@ def test_parse_job_detail_extracts_description_and_fields():
assert (sal["salaryMin"], sal["salaryMax"], sal["period"]) == (60000, 90000, "year")
+def test_parse_job_detail_reads_rootprops_shape():
+ # Live /viewjob assigns window._rootProps (JSON) with the model under
+ # preloadedVJData; window._initialData is now a non-JSON JS literal that
+ # references other globals and must be skipped, not misparsed.
+ html = (
+ ""
+ ''
+ )
+ detail = parse_job_detail(html)
+ assert detail["title"] == "Data Analyst"
+ assert detail["company"] == "Acme"
+ assert detail["descriptionHtml"] == "Full JD text
"
+ assert detail["descriptionText"] == "Full JD text"
+
+
def test_parse_job_detail_omits_blank_fields():
# A page with a header but no salary/description must not emit those keys,
# so a merge won't clobber listing values with blanks.
From b171002d15de8e4fd57d0ba1fe9b2bc0a920c358 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 19:57:19 +0200
Subject: [PATCH 064/217] test: add live indeed scraper e2e harness
---
.../scripts/e2e_indeed_scraper.py | 149 ++++++++++++++++++
1 file changed, 149 insertions(+)
create mode 100644 surfsense_backend/scripts/e2e_indeed_scraper.py
diff --git a/surfsense_backend/scripts/e2e_indeed_scraper.py b/surfsense_backend/scripts/e2e_indeed_scraper.py
new file mode 100644
index 000000000..24143af4f
--- /dev/null
+++ b/surfsense_backend/scripts/e2e_indeed_scraper.py
@@ -0,0 +1,149 @@
+"""Manual functional e2e for the Indeed scraper (app/proprietary/platforms/indeed_jobs).
+
+Run from the backend directory:
+ cd surfsense_backend
+ uv run python scripts/e2e_indeed_scraper.py
+ # or: .venv/bin/python scripts/e2e_indeed_scraper.py
+
+This is NOT a pytest test (it needs live network + a residential/custom proxy).
+All steps share one warmed browser session:
+
+ Step 0 — go/no-go probe: open a session, fetch a live search page, and assert
+ its embedded job-cards blob parses into results. If this fails the whole
+ approach is blocked on this IP/proxy — later steps are skipped.
+ Step 1 — search query -> job items; keep one discovered /viewjob URL.
+ Step 2 — scrape a search URL via startUrls.
+ Step 3 — scrape the discovered /viewjob URL and assert a full description.
+ Step 4 — scrape_job_details enrichment: a query with detail pages fetched.
+"""
+
+import asyncio
+import sys
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+# --- bootstrap: load .env and put the backend root on sys.path before app.* ---
+_BACKEND_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(_BACKEND_ROOT))
+for _candidate in (_BACKEND_ROOT / ".env", _BACKEND_ROOT.parent / ".env"):
+ if _candidate.exists():
+ load_dotenv(_candidate)
+ break
+
+from app.proprietary.platforms.indeed_jobs import ( # noqa: E402
+ IndeedScrapeInput,
+ scrape_indeed,
+)
+from app.proprietary.platforms.indeed_jobs.fetch import open_session # noqa: E402
+from app.proprietary.platforms.indeed_jobs.parsers import ( # noqa: E402
+ extract_jobcards_blob,
+ job_results,
+)
+from app.proprietary.platforms.indeed_jobs.url_resolver import ( # noqa: E402
+ build_search_url,
+)
+
+_QUERY = "data analyst"
+_LOCATION = "Remote"
+
+
+def _hr(title: str) -> None:
+ print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")
+
+
+def _check(label: str, ok: bool, detail: str = "") -> bool:
+ print(f" [{'PASS' if ok else 'FAIL'}] {label}{f' — {detail}' if detail else ''}")
+ return ok
+
+
+async def step0_probe(sess, state: dict) -> bool:
+ _hr("STEP 0 — go/no-go: warmed session + parseable job cards")
+ url = build_search_url(_QUERY, location=_LOCATION)
+ html = await sess.fetch_html(url)
+ raws = job_results(extract_jobcards_blob(html))
+ print(f" {url} -> job_cards={len(raws)}")
+ return _check("search page parsed job cards", len(raws) > 0, f"{len(raws)} cards")
+
+
+async def step1_search(sess, state: dict) -> bool:
+ _hr("STEP 1 — search query -> items")
+ items = await scrape_indeed(
+ IndeedScrapeInput(queries=[_QUERY], location=_LOCATION, maxItems=5),
+ limit=5,
+ session=sess,
+ )
+ for it in items[:5]:
+ print(f" - {it.get('jobKey')} | {it.get('title')} @ {it.get('company')}")
+ state["job_url"] = next(
+ (it["jobUrl"] for it in items if it.get("jobUrl")), None
+ )
+ return _check("search returned jobs", len(items) > 0, f"{len(items)} jobs")
+
+
+async def step2_search_url(sess, state: dict) -> bool:
+ _hr("STEP 2 — scrape a search URL (startUrls)")
+ url = build_search_url(_QUERY, location=_LOCATION)
+ items = await scrape_indeed(
+ IndeedScrapeInput(startUrls=[{"url": url}], maxItems=5),
+ limit=5,
+ session=sess,
+ )
+ return _check("search URL returned jobs", len(items) > 0, f"{len(items)} jobs")
+
+
+async def step3_viewjob(sess, state: dict) -> bool:
+ _hr("STEP 3 — scrape a discovered /viewjob URL (full description)")
+ url = state.get("job_url")
+ if not url:
+ return _check("had a discovered job URL", False)
+ items = await scrape_indeed(
+ IndeedScrapeInput(startUrls=[{"url": url}], maxItems=1),
+ limit=1,
+ session=sess,
+ )
+ desc = items[0].get("descriptionText") if items else None
+ print(f" url={url}\n description_chars={len(desc or '')}")
+ return _check("viewjob returned a description", bool(desc), url)
+
+
+async def step4_enrich(sess, state: dict) -> bool:
+ _hr("STEP 4 — search with scrape_job_details enrichment")
+ items = await scrape_indeed(
+ IndeedScrapeInput(
+ queries=[_QUERY],
+ location=_LOCATION,
+ maxItems=3,
+ scrapeJobDetails=True,
+ ),
+ limit=3,
+ session=sess,
+ )
+ # descriptionHtml is set only by the detail page; listings never carry it,
+ # so it proves enrichment actually merged the /viewjob model.
+ enriched = [i for i in items if i.get("descriptionHtml")]
+ return _check(
+ "enriched items carry full descriptionHtml",
+ bool(enriched),
+ f"{len(enriched)}/{len(items)} enriched",
+ )
+
+
+async def main() -> int:
+ state: dict = {}
+ async with open_session() as sess:
+ results = [await step0_probe(sess, state)]
+ if not results[-1]:
+ print("\nprobe failed — Indeed is blocking this IP/proxy. Aborting.")
+ return 1
+ results.append(await step1_search(sess, state))
+ results.append(await step2_search_url(sess, state))
+ results.append(await step3_viewjob(sess, state))
+ results.append(await step4_enrich(sess, state))
+ _hr("SUMMARY")
+ print(f" {sum(results)}/{len(results)} steps passed")
+ return 0 if all(results) else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(asyncio.run(main()))
From 6180b969129ac4b878dabb0fca22554c3746954e Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 20:24:22 +0200
Subject: [PATCH 065/217] fix: fail fast on gated indeed pagination, keep
collected pages
---
.../platforms/indeed_jobs/fetch.py | 12 +++++---
.../platforms/indeed_jobs/scraper.py | 27 ++++++++++++++---
.../indeed_jobs/test_fetch_resilience.py | 10 +++++++
.../platforms/indeed_jobs/test_scraper.py | 29 +++++++++++++++++--
4 files changed, 68 insertions(+), 10 deletions(-)
diff --git a/surfsense_backend/app/proprietary/platforms/indeed_jobs/fetch.py b/surfsense_backend/app/proprietary/platforms/indeed_jobs/fetch.py
index 9f913a579..c3be9c2a8 100644
--- a/surfsense_backend/app/proprietary/platforms/indeed_jobs/fetch.py
+++ b/surfsense_backend/app/proprietary/platforms/indeed_jobs/fetch.py
@@ -140,14 +140,18 @@ class IndeedSession:
await self._timed_fetch(f"https://{domain}/", google_search=True)
self._warmed.add(domain)
- async def fetch_html(self, url: str) -> str:
+ async def fetch_html(self, url: str, *, max_rotations: int | None = None) -> str:
"""Return a search/company/job page's HTML through the warmed session.
Rotates the IP and re-warms on a security-wall bounce or timeout; raises
- :class:`IndeedAccessBlockedError` once every rotation is exhausted.
+ :class:`IndeedAccessBlockedError` once rotations are exhausted. ``max_rotations``
+ overrides the default budget: pass ``0`` to fail fast on a systematically
+ gated page (e.g. anonymous pagination) instead of burning rotations on a
+ block no fresh IP will clear.
"""
if self._session is None:
await self.start()
+ budget = _MAX_ROTATIONS if max_rotations is None else max_rotations
domain = urlparse(url).hostname or "www.indeed.com"
attempt = 0
while True:
@@ -163,9 +167,9 @@ class IndeedSession:
except Exception as e:
logger.warning("[indeed] fetch failed on %s: %s", url, e)
- if attempt >= _MAX_ROTATIONS:
+ if attempt >= budget:
raise IndeedAccessBlockedError(
- f"Indeed refused {url} on {attempt} rotated IPs"
+ f"Indeed refused {url} after {attempt + 1} attempt(s)"
)
attempt += 1
await self._rotate()
diff --git a/surfsense_backend/app/proprietary/platforms/indeed_jobs/scraper.py b/surfsense_backend/app/proprietary/platforms/indeed_jobs/scraper.py
index 0c9fcfb16..01371d281 100644
--- a/surfsense_backend/app/proprietary/platforms/indeed_jobs/scraper.py
+++ b/surfsense_backend/app/proprietary/platforms/indeed_jobs/scraper.py
@@ -2,7 +2,9 @@
:func:`iter_indeed` streams flat job items from one warmed browser session:
``startUrls`` (search/company pages) or ``queries`` are paged by the ``start``
-offset, deduped by ``jobKey``, and stopped on the first page that adds nothing.
+offset, deduped by ``jobKey``, and stopped on the first page that adds nothing
+or that Indeed gates (anonymous pagination is bounced past page 1, so a gated
+page ends that target and keeps the pages already yielded).
:func:`scrape_indeed` collects the stream under a caller ``limit``.
Targets run sequentially on a single session — a browser per target would cost
@@ -16,7 +18,7 @@ from collections.abc import AsyncIterator
from typing import Any
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
-from .fetch import IndeedSession, now_iso, open_session
+from .fetch import IndeedAccessBlockedError, IndeedSession, now_iso, open_session
from .parsers import (
extract_jobcards_blob,
job_results,
@@ -60,7 +62,21 @@ async def _paginate(
seen: set[str] = set()
emitted = 0
for page in range(_MAX_PAGES):
- html = await session.fetch_html(_with_start(first_url, page * _PAGE_STEP))
+ try:
+ # Rotate to establish first-page access, but fail fast on deeper
+ # pages: Indeed gates anonymous pagination to secure.indeed.com, a
+ # block no fresh IP clears — retrying it only burns the time budget.
+ # ponytail: deeper pages are dropped rather than retried; lift the
+ # cap with an async job if full-depth pagination is ever needed.
+ html = await session.fetch_html(
+ _with_start(first_url, page * _PAGE_STEP),
+ max_rotations=None if page == 0 else 0,
+ )
+ except IndeedAccessBlockedError:
+ if page == 0:
+ raise # never got in on this target; surface the block
+ logger.info("[indeed] pagination gated at start=%d; stopping", page * _PAGE_STEP)
+ return # keep the pages already yielded
raws = job_results(extract_jobcards_blob(html))
if not raws:
return
@@ -127,7 +143,10 @@ async def _enrich(session: IndeedSession, item: dict[str, Any], base_url: str) -
if not isinstance(job_url, str):
return
try:
- detail = parse_job_detail(await session.fetch_html(job_url), base_url=base_url)
+ # Fail fast: enrichment is best-effort, so a gated detail page must not
+ # rotate IPs and eat the run's time budget for one job's description.
+ html = await session.fetch_html(job_url, max_rotations=0)
+ detail = parse_job_detail(html, base_url=base_url)
except Exception as exc:
logger.warning("[indeed] detail fetch failed for %s: %s", job_url, exc)
return
diff --git a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_fetch_resilience.py b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_fetch_resilience.py
index 4eae65f84..258f5154d 100644
--- a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_fetch_resilience.py
+++ b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_fetch_resilience.py
@@ -85,6 +85,16 @@ async def test_raises_after_exhausting_rotations():
assert session.rotations == 3
+@pytest.mark.asyncio
+async def test_max_rotations_zero_fails_fast():
+ # A gated page (pagination) must raise on the first block without rotating.
+ ctrl = _Controller(["BLOCK", "OK"])
+ session = IndeedSession(ctrl.factory)
+ with pytest.raises(IndeedAccessBlockedError):
+ await session.fetch_html(_URL, max_rotations=0)
+ assert session.rotations == 0
+
+
@pytest.mark.asyncio
async def test_warms_domain_once_without_rotation():
ctrl = _Controller(["OK", "OK"])
diff --git a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py
index e5ea23452..acf52296b 100644
--- a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py
+++ b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py
@@ -7,6 +7,7 @@ from urllib.parse import parse_qs, urlparse
import pytest
+from app.proprietary.platforms.indeed_jobs.fetch import IndeedAccessBlockedError
from app.proprietary.platforms.indeed_jobs.schemas import IndeedScrapeInput
from app.proprietary.platforms.indeed_jobs.scraper import iter_indeed, scrape_indeed
@@ -38,16 +39,21 @@ def _detail_html(job_key: str) -> str:
class _FakeSession:
"""Returns per-``start`` search pages (or a /viewjob detail) and records URLs."""
- def __init__(self, pages: dict[int, list[str]]) -> None:
+ def __init__(
+ self, pages: dict[int, list[str]], blocked_starts: set[int] | None = None
+ ) -> None:
self._pages = pages
+ self._blocked = blocked_starts or set()
self.fetched: list[str] = []
- async def fetch_html(self, url: str) -> str:
+ async def fetch_html(self, url: str, *, max_rotations: int | None = None) -> str:
self.fetched.append(url)
query = parse_qs(urlparse(url).query)
if "/viewjob" in url:
return _detail_html(query.get("jk", [""])[0])
start = int(query.get("start", ["0"])[0])
+ if start in self._blocked:
+ raise IndeedAccessBlockedError(f"gated at start={start}")
return _page_html(self._pages.get(start, []))
@@ -77,6 +83,25 @@ async def test_stops_when_a_page_is_all_duplicates():
}
+@pytest.mark.asyncio
+async def test_pagination_gate_keeps_earlier_pages():
+ # Indeed gates anonymous pagination: page 0 serves, start=10 is blocked.
+ # The already-yielded page-0 items must survive; paging just stops.
+ session = _FakeSession({0: ["k1", "k2"], 10: ["k3"]}, blocked_starts={10})
+ items = await _collect(
+ IndeedScrapeInput(queries=["dev"], maxItemsPerQuery=100), session
+ )
+ assert [i["jobKey"] for i in items] == ["k1", "k2"]
+
+
+@pytest.mark.asyncio
+async def test_first_page_block_propagates():
+ # A block on the very first page yielded nothing, so it surfaces as an error.
+ session = _FakeSession({}, blocked_starts={0})
+ with pytest.raises(IndeedAccessBlockedError):
+ await _collect(IndeedScrapeInput(queries=["dev"]), session)
+
+
@pytest.mark.asyncio
async def test_respects_max_items_per_query():
session = _FakeSession({0: ["k1", "k2", "k3", "k4"]})
From c485fe4c43fc900ab75b16e49efc2dca5aa7fd3f Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 15 Jul 2026 20:54:37 +0200
Subject: [PATCH 066/217] fix: scrape indeed first page only, steer subagent to
one query
---
.../builtins/indeed/system_prompt.md | 7 +-
.../platforms/indeed_jobs/scraper.py | 86 ++++++-------------
.../platforms/indeed_jobs/test_scraper.py | 30 ++-----
3 files changed, 36 insertions(+), 87 deletions(-)
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/system_prompt.md b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/system_prompt.md
index b034b700a..36eb855d2 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/system_prompt.md
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/indeed/system_prompt.md
@@ -14,10 +14,9 @@ Answer the delegated question from live Indeed job data gathered with your verb,
- Finding jobs for a role: call `indeed_scrape` with `search_queries`; narrow with `location`, `country`, `job_type`, `level`, `remote`, `radius`, and `from_days`.
- Scraping a specific Indeed URL: pass a search, company jobs, or single job URL in `urls`.
- Full descriptions: set `scrape_job_details=true` to fetch each job's detail page (slower: one extra load per job). Leave it false when the listing snippet is enough.
-- Controlling volume: use `max_items` for the total cap and `max_items_per_query` per search.
-- Requested counts: `max_items` defaults to only 25 — when the task asks for N jobs, set `max_items` and `max_items_per_query` above N (with headroom for off-topic hits). A call that caps below the target can never satisfy it.
-- Under-delivery: if the first call returns fewer on-topic results than requested, broaden it yourself — more query phrasings, wider `radius`, drop restrictive filters, larger `from_days` — before settling. Return `status=partial` only after the broadened attempt, never after a single narrow call.
-- Batch multiple search terms into one call rather than many single-term calls.
+- Cost model: Indeed is latency-bound. A cold session spends minutes solving Cloudflare, and each query returns only its first page (~15 jobs) — anonymous pagination is gated, so `max_items` above ~15/query buys nothing. Every extra phrasing adds wait, not depth.
+- Default to ONE focused query. Only add phrasings when the role genuinely needs variety, and then put at most 2–3 in a SINGLE call's `search_queries` (they reuse one warmed session); never make separate `indeed_scrape` calls, which each pay the cold-start cost.
+- Prefer returning the first page's on-topic hits promptly over exhaustive coverage. If a single call under-delivers against a large requested N, return `status=partial` noting the ~one-page-per-query ceiling — do not chase it with more calls.
- Comparison requests: pull the current results, compare against prior values already in this conversation's earlier tool results, and report concrete deltas (added, removed, salary/rank changes).
diff --git a/surfsense_backend/app/proprietary/platforms/indeed_jobs/scraper.py b/surfsense_backend/app/proprietary/platforms/indeed_jobs/scraper.py
index 01371d281..68b70870a 100644
--- a/surfsense_backend/app/proprietary/platforms/indeed_jobs/scraper.py
+++ b/surfsense_backend/app/proprietary/platforms/indeed_jobs/scraper.py
@@ -1,14 +1,8 @@
"""Orchestrator for the Indeed scraper.
-:func:`iter_indeed` streams flat job items from one warmed browser session:
-``startUrls`` (search/company pages) or ``queries`` are paged by the ``start``
-offset, deduped by ``jobKey``, and stopped on the first page that adds nothing
-or that Indeed gates (anonymous pagination is bounced past page 1, so a gated
-page ends that target and keeps the pages already yielded).
-:func:`scrape_indeed` collects the stream under a caller ``limit``.
-
-Targets run sequentially on a single session — a browser per target would cost
-far more than the sequential paging it would save.
+:func:`iter_indeed` streams deduped job items from one warmed session; each
+search/company target contributes its first page. :func:`scrape_indeed` collects
+the stream under a caller ``limit``. Targets run sequentially to reuse the session.
"""
from __future__ import annotations
@@ -16,9 +10,9 @@ from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from typing import Any
-from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
+from urllib.parse import urlparse
-from .fetch import IndeedAccessBlockedError, IndeedSession, now_iso, open_session
+from .fetch import IndeedSession, now_iso, open_session
from .parsers import (
extract_jobcards_blob,
job_results,
@@ -32,68 +26,36 @@ logger = logging.getLogger(__name__)
__all__ = ["iter_indeed", "scrape_indeed"]
-# Indeed's search pagination increments the ``start`` offset by 10.
-_PAGE_STEP = 10
-# Indeed stops serving useful results past ~1000; cap pages well before that.
-_MAX_PAGES = 10
-
def _emit(partial: dict[str, Any]) -> dict[str, Any]:
"""Stamp ``scrapedAt`` and normalize through the output model."""
return IndeedItem(**{**partial, "scrapedAt": now_iso()}).to_output()
-def _with_start(url: str, start: int) -> str:
- """Return ``url`` with its ``start`` query param set (removed when 0)."""
- parsed = urlparse(url)
- params = [(k, v) for k, v in parse_qsl(parsed.query) if k != "start"]
- if start:
- params.append(("start", str(start)))
- return urlunparse(parsed._replace(query=urlencode(params)))
-
-
-async def _paginate(
- session: IndeedSession, first_url: str, *, domain: str, max_items: int
+async def _search_items(
+ session: IndeedSession, url: str, *, domain: str, max_items: int
) -> AsyncIterator[dict[str, Any]]:
- """Yield items across pages of one search/company URL, deduped by ``jobKey``."""
+ """Yield deduped job cards from one search/company page.
+
+ ponytail: caps a query at its first page (~15 jobs) — anonymous Indeed gates
+ ``start>=10``; deeper depth needs an authenticated session or Indeed's API.
+ """
if max_items <= 0:
return
base_url = f"https://{domain}"
+ html = await session.fetch_html(url)
seen: set[str] = set()
emitted = 0
- for page in range(_MAX_PAGES):
- try:
- # Rotate to establish first-page access, but fail fast on deeper
- # pages: Indeed gates anonymous pagination to secure.indeed.com, a
- # block no fresh IP clears — retrying it only burns the time budget.
- # ponytail: deeper pages are dropped rather than retried; lift the
- # cap with an async job if full-depth pagination is ever needed.
- html = await session.fetch_html(
- _with_start(first_url, page * _PAGE_STEP),
- max_rotations=None if page == 0 else 0,
- )
- except IndeedAccessBlockedError:
- if page == 0:
- raise # never got in on this target; surface the block
- logger.info("[indeed] pagination gated at start=%d; stopping", page * _PAGE_STEP)
- return # keep the pages already yielded
- raws = job_results(extract_jobcards_blob(html))
- if not raws:
- return
- added = 0
- for raw in raws:
- item = parse_job(raw, base_url=base_url)
- job_key = item.get("jobKey")
- if isinstance(job_key, str):
- if job_key in seen:
- continue
- seen.add(job_key)
- yield _emit(item)
- emitted += 1
- added += 1
- if emitted >= max_items:
- return
- if added == 0: # a whole page of duplicates: end of useful results
+ for raw in job_results(extract_jobcards_blob(html)):
+ item = parse_job(raw, base_url=base_url)
+ job_key = item.get("jobKey")
+ if isinstance(job_key, str):
+ if job_key in seen:
+ continue
+ seen.add(job_key)
+ yield _emit(item)
+ emitted += 1
+ if emitted >= max_items:
return
@@ -175,7 +137,7 @@ async def iter_indeed(
if item is not None:
yield item
continue
- async for item in _paginate(
+ async for item in _search_items(
session, url, domain=domain, max_items=input_model.maxItemsPerQuery
):
job_key = item.get("jobKey")
diff --git a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py
index acf52296b..1d815ce98 100644
--- a/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py
+++ b/surfsense_backend/tests/unit/platforms/indeed_jobs/test_scraper.py
@@ -62,41 +62,29 @@ async def _collect(input_model, session) -> list[dict]:
@pytest.mark.asyncio
-async def test_paginates_and_dedupes_across_pages():
- session = _FakeSession({0: ["k1", "k2", "k3"], 10: ["k3", "k4"], 20: []})
+async def test_dedupes_within_page():
+ session = _FakeSession({0: ["k1", "k2", "k2", "k3"]})
items = await _collect(
IndeedScrapeInput(queries=["dev"], maxItemsPerQuery=100), session
)
- assert [i["jobKey"] for i in items] == ["k1", "k2", "k3", "k4"]
+ assert [i["jobKey"] for i in items] == ["k1", "k2", "k3"]
assert all(i["scrapedAt"] for i in items) # stamped by the orchestrator
@pytest.mark.asyncio
-async def test_stops_when_a_page_is_all_duplicates():
- session = _FakeSession({0: ["k1", "k2"], 10: ["k1", "k2"], 20: ["k9"]})
+async def test_does_not_fetch_deeper_pages():
+ # First page only; ``start>=10`` must never be requested.
+ session = _FakeSession({0: ["k1", "k2"], 10: ["k3"]})
items = await _collect(
IndeedScrapeInput(queries=["dev"], maxItemsPerQuery=100), session
)
assert [i["jobKey"] for i in items] == ["k1", "k2"]
- assert 20 not in {
- int(parse_qs(urlparse(u).query).get("start", ["0"])[0]) for u in session.fetched
- }
+ assert all("start=" not in u for u in session.fetched)
@pytest.mark.asyncio
-async def test_pagination_gate_keeps_earlier_pages():
- # Indeed gates anonymous pagination: page 0 serves, start=10 is blocked.
- # The already-yielded page-0 items must survive; paging just stops.
- session = _FakeSession({0: ["k1", "k2"], 10: ["k3"]}, blocked_starts={10})
- items = await _collect(
- IndeedScrapeInput(queries=["dev"], maxItemsPerQuery=100), session
- )
- assert [i["jobKey"] for i in items] == ["k1", "k2"]
-
-
-@pytest.mark.asyncio
-async def test_first_page_block_propagates():
- # A block on the very first page yielded nothing, so it surfaces as an error.
+async def test_page_block_propagates():
+ # Nothing yielded before the block, so it surfaces as an error.
session = _FakeSession({}, blocked_starts={0})
with pytest.raises(IndeedAccessBlockedError):
await _collect(IndeedScrapeInput(queries=["dev"]), session)
From 7246f89c124fe7c515350777ac24c4aad393f7b3 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 01:29:18 +0530
Subject: [PATCH 067/217] refactor(chat): enhance message loading state
management and update skeleton loading components for better user experience
---
.../new-chat/[[...chat_id]]/page.tsx | 104 +++++-------------
.../components/assistant-ui/thread.tsx | 89 ++++++++++++---
2 files changed, 98 insertions(+), 95 deletions(-)
diff --git a/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx
index f5b857ce1..22298e574 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx
@@ -39,7 +39,6 @@ import {
TokenUsageProvider,
} from "@/components/assistant-ui/token-usage-context";
import { Button } from "@/components/ui/button";
-import { Skeleton } from "@/components/ui/skeleton";
import { useSyncChatArtifacts } from "@/features/chat-artifacts";
import {
type HitlDecision,
@@ -104,6 +103,7 @@ const MobileArtifactsPanel = dynamic(
/** Stable empty reference so idle threads don't re-render the interrupt provider. */
const EMPTY_PENDING_INTERRUPTS: PendingInterruptState[] = [];
+const EMPTY_MESSAGES: ThreadMessageLike[] = [];
function parseUrlChatId(id: string | string[] | undefined): number {
let parsed = 0;
@@ -115,61 +115,6 @@ function parseUrlChatId(id: string | string[] | undefined): number {
return Number.isNaN(parsed) ? 0 : parsed;
}
-function ThreadMessagesSkeleton() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
export default function NewChatPage() {
const params = useParams();
const queryClient = useQueryClient();
@@ -226,6 +171,16 @@ export default function NewChatPage() {
const threadDetailQuery = useThreadDetail(activeThreadId);
const threadMessagesQuery = useThreadMessages(activeThreadId);
+ const hydratedMessagesRef = useRef<{
+ threadId: number | null;
+ data: typeof threadMessagesQuery.data;
+ }>({ threadId: null, data: undefined });
+ const hasLiveStream = !!streamState && streamState.messages.length > 0;
+ const isActiveThreadHydrated = hydratedMessagesRef.current.threadId === activeThreadId;
+ const shouldHideStaleMessages = !!activeThreadId && !hasLiveStream && !isActiveThreadHydrated;
+ const isThreadMessagesLoading =
+ shouldHideStaleMessages && !threadMessagesQuery.error;
+ const runtimeMessages = shouldHideStaleMessages ? EMPTY_MESSAGES : displayMessages;
// Live collaboration: sync session state and messages via Zero. Kept on the
// page because "AI responding" reflects the currently-viewed thread.
@@ -296,8 +251,8 @@ export default function NewChatPage() {
// Latest displayed messages, read by the engine wrappers at call time so
// history/slice seeds stay fresh without re-creating the callbacks.
- const messagesRef = useRef(displayMessages);
- messagesRef.current = displayMessages;
+ const messagesRef = useRef(runtimeMessages);
+ messagesRef.current = runtimeMessages;
const buildCtx = useCallback(
(): EngineContext => ({
@@ -309,11 +264,6 @@ export default function NewChatPage() {
[workspaceId, activeThreadId]
);
- const hydratedMessagesRef = useRef<{
- threadId: number | null;
- data: typeof threadMessagesQuery.data;
- }>({ threadId: null, data: undefined });
-
// Reset thread-local runtime state on route/workspace changes. The durable
// streaming overlay is preserved for any still-running thread (and the newly
// viewed thread) via ``clearInactive`` so an in-flight turn survives nav.
@@ -517,8 +467,11 @@ export default function NewChatPage() {
// Handle new message from user
const onNew = useCallback(
- (message: AppendMessage) => startNewChat(buildCtx(), message),
- [buildCtx]
+ (message: AppendMessage) => {
+ if (isThreadMessagesLoading) return Promise.resolve();
+ return startNewChat(buildCtx(), message);
+ },
+ [buildCtx, isThreadMessagesLoading]
);
// Cancel the in-flight turn (targets the active stream's owner thread).
@@ -747,11 +700,11 @@ export default function NewChatPage() {
}, [buildCtx, pendingInterrupts, activeThreadId]);
// Surface the thread's deliverables to the layout-level artifacts sidebar.
- useSyncChatArtifacts(displayMessages);
+ useSyncChatArtifacts(runtimeMessages);
// Create external store runtime
const runtime = useExternalStoreRuntime({
- messages: displayMessages,
+ messages: runtimeMessages,
isRunning,
onNew,
onEdit,
@@ -764,12 +717,7 @@ export default function NewChatPage() {
? (threadDetailQuery.error ?? threadMessagesQuery.error)
: null;
const shouldShowThreadLoadError =
- !!threadLoadError && !!activeThreadId && !currentThread && displayMessages.length === 0;
- const isThreadMessagesLoading =
- !!activeThreadId &&
- threadMessagesQuery.isPending &&
- displayMessages.length === 0 &&
- !threadMessagesQuery.error;
+ !!threadLoadError && !!activeThreadId && !currentThread && runtimeMessages.length === 0;
if (shouldShowThreadLoadError) {
return (
@@ -798,12 +746,10 @@ export default function NewChatPage() {
>
-
- {isThreadMessagesLoading ? (
-
-
-
- ) : null}
+
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx
index c51bea165..634d9bd8b 100644
--- a/surfsense_web/components/assistant-ui/thread.tsx
+++ b/surfsense_web/components/assistant-ui/thread.tsx
@@ -103,6 +103,7 @@ import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture";
+import { canSubmitChat } from "@/lib/chat/can-submit-chat";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { slideoutOpenedTickAtom } from "@/lib/layout-events";
import { findPlatform, type PlaygroundPlatform } from "@/lib/playground/catalog";
@@ -147,13 +148,19 @@ function getComposerSuggestionAnchorPoint(
interface ThreadProps {
hasActiveThread?: boolean;
+ isLoadingMessages?: boolean;
}
-export const Thread: FC
= ({ hasActiveThread = false }) => {
- return ;
+export const Thread: FC = ({ hasActiveThread = false, isLoadingMessages = false }) => {
+ return (
+
+ );
};
-const ThreadContent: FC = ({ hasActiveThread = false }) => {
+const ThreadContent: FC = ({
+ hasActiveThread = false,
+ isLoadingMessages = false,
+}) => {
return (
= ({ hasActiveThread = false }) => {
footer={
hasActiveThread || !thread.isEmpty}>
-
+
}
>
@@ -173,6 +180,8 @@ const ThreadContent: FC = ({ hasActiveThread = false }) => {
+ {isLoadingMessages ? : null}
+
= ({ hasActiveThread = false }) => {
);
};
+const ThreadMessagesSkeletonBody: FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
const PremiumQuotaPinnedAlert: FC = () => {
const currentThreadState = useAtomValue(currentThreadAtom);
const alertsByThread = useAtomValue(premiumAlertByThreadAtom);
@@ -490,7 +529,11 @@ const ChatUnavailableNotice: FC<{ workspaceId: number; canConfigure: boolean }>
);
};
-const Composer: FC = () => {
+interface ComposerProps {
+ isLoadingMessages?: boolean;
+}
+
+const Composer: FC = ({ isLoadingMessages = false }) => {
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const setSubmittedMentions = useSetAtom(submittedMentionsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
@@ -823,7 +866,7 @@ const Composer: FC = () => {
);
const handleSubmit = useCallback(() => {
- if (isThreadRunning || isBlockedByOtherUser) return;
+ if (isLoadingMessages || isThreadRunning || isBlockedByOtherUser) return;
if (showDocumentPopover || showPromptPicker) return;
if (clipboardInitialText) {
@@ -844,6 +887,7 @@ const Composer: FC = () => {
}, [
showDocumentPopover,
showPromptPicker,
+ isLoadingMessages,
isThreadRunning,
isBlockedByOtherUser,
clipboardInitialText,
@@ -1016,15 +1060,17 @@ const Composer: FC = () => {
- {isThreadEmpty && isComposerInputEmpty ? (
+ {!isLoadingMessages && isThreadEmpty && isComposerInputEmpty ? (
@@ -1087,12 +1133,16 @@ const ConnectedScraperIcons: FC<{ workspaceId: number }> = ({ workspaceId }) =>
interface ComposerActionProps {
isBlockedByOtherUser?: boolean;
+ isLoadingMessages?: boolean;
+ isThreadRunning?: boolean;
workspaceId: number;
onChatModelSelected?: () => void;
}
const ComposerAction: FC = ({
isBlockedByOtherUser = false,
+ isLoadingMessages = false,
+ isThreadRunning = false,
workspaceId,
onChatModelSelected,
}) => {
@@ -1210,7 +1260,20 @@ const ComposerAction: FC = ({
// send that lacks a resolvable model, making this defense-in-depth.
const isWorkspaceChatReady = setupStatus?.status === "ready";
- const isSendDisabled = isComposerEmpty || !isWorkspaceChatReady || isBlockedByOtherUser;
+ const isSendDisabled = !canSubmitChat({
+ isLoadingMessages,
+ isThreadRunning,
+ isBlockedByOtherUser,
+ isComposerEmpty,
+ isWorkspaceChatReady,
+ });
+ const sendTooltip = isLoadingMessages
+ ? "Loading conversation..."
+ : isBlockedByOtherUser
+ ? "Wait for AI to finish responding"
+ : isComposerEmpty
+ ? "Enter a message or add a screenshot to send"
+ : "Send message";
return (
@@ -1661,13 +1724,7 @@ const ComposerAction: FC
= ({
!thread.isRunning}>
Date: Thu, 16 Jul 2026 01:29:32 +0530
Subject: [PATCH 068/217] feat(chat): add CanSubmitChat interface and logic to
determine chat submission eligibility
---
surfsense_web/lib/chat/can-submit-chat.ts | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
create mode 100644 surfsense_web/lib/chat/can-submit-chat.ts
diff --git a/surfsense_web/lib/chat/can-submit-chat.ts b/surfsense_web/lib/chat/can-submit-chat.ts
new file mode 100644
index 000000000..106b298af
--- /dev/null
+++ b/surfsense_web/lib/chat/can-submit-chat.ts
@@ -0,0 +1,23 @@
+export interface CanSubmitChatInput {
+ isLoadingMessages: boolean;
+ isThreadRunning: boolean;
+ isBlockedByOtherUser: boolean;
+ isComposerEmpty: boolean;
+ isWorkspaceChatReady: boolean;
+}
+
+export function canSubmitChat({
+ isLoadingMessages,
+ isThreadRunning,
+ isBlockedByOtherUser,
+ isComposerEmpty,
+ isWorkspaceChatReady,
+}: CanSubmitChatInput): boolean {
+ return (
+ !isLoadingMessages &&
+ !isThreadRunning &&
+ !isBlockedByOtherUser &&
+ !isComposerEmpty &&
+ isWorkspaceChatReady
+ );
+}
From b100eda8c63f30178938ced147fb9b6ecaf66df6 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 13:11:54 +0530
Subject: [PATCH 069/217] refactor(chat-header): update className handling for
responsive design and improve layout consistency
---
surfsense_web/components/assistant-ui/thread.tsx | 6 +++---
surfsense_web/components/new-chat/chat-header.tsx | 9 ++++++---
2 files changed, 9 insertions(+), 6 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx
index 634d9bd8b..f11e82952 100644
--- a/surfsense_web/components/assistant-ui/thread.tsx
+++ b/surfsense_web/components/assistant-ui/thread.tsx
@@ -102,8 +102,8 @@ import { useCommentsSync } from "@/hooks/use-comments-sync";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
-import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture";
import { canSubmitChat } from "@/lib/chat/can-submit-chat";
+import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { slideoutOpenedTickAtom } from "@/lib/layout-events";
import { findPlatform, type PlaygroundPlatform } from "@/lib/playground/catalog";
@@ -1715,10 +1715,10 @@ const ComposerAction: FC = ({
)}
-
+
!thread.isRunning}>
diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx
index e83a95d6c..f315f7645 100644
--- a/surfsense_web/components/new-chat/chat-header.tsx
+++ b/surfsense_web/components/new-chat/chat-header.tsx
@@ -1,5 +1,6 @@
"use client";
+import { cn } from "@/lib/utils";
import { ImageModelSelector } from "./image-model-selector";
import { ModelSelector } from "./model-selector";
@@ -10,14 +11,16 @@ interface ChatHeaderProps {
}
export function ChatHeader({ workspaceId, className, onChatModelSelected }: ChatHeaderProps) {
+ const selectorClassName = cn(className, "sm:max-w-[180px] sm:min-w-0");
+
return (
-
+
-
+
);
}
From 804e5e42f3da0ed4d05b15ee54b98dfd6fa630d6 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 13:34:27 +0530
Subject: [PATCH 070/217] refactor(citation-panel): remove showHeader prop and
enhance close button functionality in CitationPanelContent
---
.../assistant-ui/inline-citation.tsx | 2 +-
.../citation-panel/citation-panel.tsx | 48 ++++++++-----------
2 files changed, 22 insertions(+), 28 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/inline-citation.tsx b/surfsense_web/components/assistant-ui/inline-citation.tsx
index a66c22fa3..a846e8311 100644
--- a/surfsense_web/components/assistant-ui/inline-citation.tsx
+++ b/surfsense_web/components/assistant-ui/inline-citation.tsx
@@ -100,7 +100,7 @@ const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
Citation
-
+
diff --git a/surfsense_web/components/citation-panel/citation-panel.tsx b/surfsense_web/components/citation-panel/citation-panel.tsx
index 34cc89c8b..f501281b9 100644
--- a/surfsense_web/components/citation-panel/citation-panel.tsx
+++ b/surfsense_web/components/citation-panel/citation-panel.tsx
@@ -8,6 +8,7 @@ import { useEffect, useMemo, useRef } from "react";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { documentsApiService } from "@/lib/apis/documents-api.service";
@@ -16,7 +17,6 @@ const DEFAULT_CHUNK_WINDOW = 5;
interface CitationPanelContentProps {
chunkId: number;
onClose?: () => void;
- showHeader?: boolean;
}
/**
@@ -25,11 +25,7 @@ interface CitationPanelContentProps {
* with the cited one visually highlighted and auto-scrolled into view.
* The user can jump to the full document via the editor panel.
*/
-export const CitationPanelContent: FC
= ({
- chunkId,
- onClose,
- showHeader = true,
-}) => {
+export const CitationPanelContent: FC = ({ chunkId, onClose }) => {
const openEditorPanel = useSetAtom(openEditorPanelAtom);
const chunkWindow = DEFAULT_CHUNK_WINDOW;
@@ -85,32 +81,13 @@ export const CitationPanelContent: FC = ({
return (
<>
- {showHeader && (
-
-
Citation
-
- {onClose && (
-
-
- Close citation panel
-
- )}
-
-
- )}
-
+
{data?.title ?? (isLoading ? "Loading…" : `Chunk #${chunkId}`)}
-
- {totalChunks > 0 &&
{totalChunks} chunks }
+
{!isLoading && !error && data && (
= ({
Open
)}
+ {onClose && (
+ <>
+
+
+
+ Close citation panel
+
+ >
+ )}
From fc2366c81e87d32aeb12f0a5b2e0ff26f9eb8712 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 13:40:44 +0530
Subject: [PATCH 071/217] refactor(report-panel): enhance header layout and
integrate Separator component for improved UI consistency
---
.../components/report-panel/report-panel.tsx | 69 ++++++++++---------
1 file changed, 38 insertions(+), 31 deletions(-)
diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx
index 1fce9848c..076d10255 100644
--- a/surfsense_web/components/report-panel/report-panel.tsx
+++ b/surfsense_web/components/report-panel/report-panel.tsx
@@ -18,6 +18,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { useMediaQuery } from "@/hooks/use-media-query";
import { baseApiService } from "@/lib/apis/base-api.service";
@@ -444,39 +445,45 @@ export function ReportPanelContent({
return (
<>
{showDesktopHeader ? (
- <>
+
{/* Header — matches the Documents panel header pattern */}
-
-
{isResume ? "Resume" : "Report"}
- {onClose && (
-
-
- Close report panel
-
- )}
-
-
- {!isResume && (
-
-
-
- {reportContent?.title || title}
-
-
-
- {versionSwitcher}
- {exportButton}
- {copyButton}
- {editingActions}
-
+
+
+
+ {isResume ? "Resume" : reportContent?.title || title}
+
- )}
- >
+
+ {!isResume && (
+ <>
+ {versionSwitcher}
+ {exportButton}
+ {copyButton}
+ {editingActions}
+ >
+ )}
+ {onClose && (
+ <>
+ {!isResume && (
+
+ )}
+
+
+ Close report panel
+
+ >
+ )}
+
+
+
) : (
!isResume && (
From bf9d536757586a28a17a4102b7d43d3c6f3ebbf9 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 13:58:45 +0530
Subject: [PATCH 072/217] refactor(artifacts-panel): improve header layout and
button styles for better UI consistency
---
.../chat-artifacts/ui/artifacts-panel.tsx | 32 +++++++++++--------
1 file changed, 19 insertions(+), 13 deletions(-)
diff --git a/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx b/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx
index 7b3567d73..8c5b575ad 100644
--- a/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx
+++ b/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx
@@ -70,19 +70,25 @@ export function ArtifactsPanelContent({ onClose }: { onClose?: () => void }) {
return (
<>
-
-
Artifacts
- {onClose && (
-
-
- Close artifacts panel
-
- )}
+
+
+
+
+ {onClose && (
+
+
+ Close artifacts panel
+
+ )}
+
+
>
From 1385ff4789e06e7f3a51b1004df2826f7bab6087 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 14:14:10 +0530
Subject: [PATCH 073/217] refactor(connect-agent-dialog): update dialog title
for clarity and improved user understanding
---
surfsense_web/components/mcp/connect-agent-dialog.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/surfsense_web/components/mcp/connect-agent-dialog.tsx b/surfsense_web/components/mcp/connect-agent-dialog.tsx
index e46f602ec..417877b2d 100644
--- a/surfsense_web/components/mcp/connect-agent-dialog.tsx
+++ b/surfsense_web/components/mcp/connect-agent-dialog.tsx
@@ -43,7 +43,7 @@ export function ConnectAgentDialog({ className }: { className?: string }) {
- Connect to Claude Code, Codex, OpenCode…
+ Connect your coding agent to SurfSense
Give your coding agent access to SurfSense scrapers and your knowledge base. Create an API key under API Keys, choose your agent, then paste the config.
From 38b784fbacb1f7f0a05e2cd2259a0d7963b8c6ff Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 15:44:49 +0530
Subject: [PATCH 074/217] feat(workspaces): implement workspace limits feature
with backend integration and UI updates
---
surfsense_backend/app/config/__init__.py | 3 +
.../app/routes/workspaces_routes.py | 22 +++++++
.../unit/routes/test_workspaces_limits.py | 63 +++++++++++++++++++
.../atoms/workspaces/workspace-query.atoms.ts | 19 ++++--
.../layout/providers/LayoutDataProvider.tsx | 20 +++++-
.../ui/dialogs/CreateWorkspaceDialog.tsx | 2 +
.../layout/ui/icon-rail/IconRail.tsx | 31 ++++++---
.../layout/ui/shell/LayoutShell.tsx | 8 +++
.../layout/ui/sidebar/MobileSidebar.tsx | 12 +++-
.../contracts/types/workspace.types.ts | 8 +++
.../lib/apis/workspaces-api.service.ts | 5 ++
surfsense_web/lib/query-client/cache-keys.ts | 1 +
12 files changed, 176 insertions(+), 18 deletions(-)
create mode 100644 surfsense_backend/tests/unit/routes/test_workspaces_limits.py
diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py
index 92e448d9a..830a2a04e 100644
--- a/surfsense_backend/app/config/__init__.py
+++ b/surfsense_backend/app/config/__init__.py
@@ -850,6 +850,9 @@ class Config:
# Auth
AUTH_TYPE = os.getenv("AUTH_TYPE", "LOCAL")
REGISTRATION_ENABLED = os.getenv("REGISTRATION_ENABLED", "TRUE").upper() == "TRUE"
+ # Max workspaces a user may own. The frontend reads this through the
+ # workspace limits route; do not duplicate this value client-side.
+ MAX_WORKSPACES_PER_USER = int(os.getenv("MAX_WORKSPACES_PER_USER", "100"))
# Google OAuth
GOOGLE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
diff --git a/surfsense_backend/app/routes/workspaces_routes.py b/surfsense_backend/app/routes/workspaces_routes.py
index 9dd3f196c..53d4344f7 100644
--- a/surfsense_backend/app/routes/workspaces_routes.py
+++ b/surfsense_backend/app/routes/workspaces_routes.py
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.auth.context import AuthContext
+from app.config import config
from app.db import (
Permission,
Workspace,
@@ -81,6 +82,22 @@ async def create_workspace(
user = auth.user
try:
workspace_data = workspace.model_dump()
+ not_deleting = ~Workspace.name.startswith("[DELETING] ")
+ owned_count = (
+ await session.execute(
+ select(func.count())
+ .select_from(Workspace)
+ .filter(Workspace.user_id == user.id, not_deleting)
+ )
+ ).scalar_one()
+ if owned_count >= config.MAX_WORKSPACES_PER_USER:
+ raise HTTPException(
+ status_code=409,
+ detail=(
+ "Workspace limit reached. You can own at most "
+ f"{config.MAX_WORKSPACES_PER_USER} workspaces."
+ ),
+ )
# citations_enabled defaults to True (handled by Pydantic schema)
# qna_custom_instructions defaults to None/empty (handled by DB)
@@ -207,6 +224,11 @@ async def read_workspaces(
) from e
+@router.get("/workspaces/limits")
+async def read_workspace_limits(_auth: AuthContext = Depends(allow_any_principal)):
+ return {"max_workspaces_per_user": config.MAX_WORKSPACES_PER_USER}
+
+
@router.get("/workspaces/{workspace_id}", response_model=WorkspaceRead)
async def read_workspace(
workspace_id: int,
diff --git a/surfsense_backend/tests/unit/routes/test_workspaces_limits.py b/surfsense_backend/tests/unit/routes/test_workspaces_limits.py
new file mode 100644
index 000000000..ab89d08bc
--- /dev/null
+++ b/surfsense_backend/tests/unit/routes/test_workspaces_limits.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+from fastapi import HTTPException
+
+from app.routes import workspaces_routes
+from app.schemas import WorkspaceCreate
+
+pytestmark = pytest.mark.unit
+
+
+class _CountResult:
+ def __init__(self, count: int):
+ self.count = count
+
+ def scalar_one(self) -> int:
+ return self.count
+
+
+class _FakeSession:
+ def __init__(self, owned_count: int):
+ self.owned_count = owned_count
+
+ async def execute(self, _statement):
+ return _CountResult(self.owned_count)
+
+
+@pytest.mark.asyncio
+async def test_read_workspace_limits_uses_backend_config(monkeypatch):
+ monkeypatch.setattr(
+ workspaces_routes.config,
+ "MAX_WORKSPACES_PER_USER",
+ 37,
+ raising=False,
+ )
+
+ result = await workspaces_routes.read_workspace_limits(_auth=SimpleNamespace())
+
+ assert result == {"max_workspaces_per_user": 37}
+
+
+@pytest.mark.asyncio
+async def test_create_workspace_rejects_when_owned_limit_reached(monkeypatch):
+ monkeypatch.setattr(
+ workspaces_routes.config,
+ "MAX_WORKSPACES_PER_USER",
+ 2,
+ raising=False,
+ )
+ auth = SimpleNamespace(user=SimpleNamespace(id="user-1"))
+ session = _FakeSession(owned_count=2)
+
+ with pytest.raises(HTTPException) as exc_info:
+ await workspaces_routes.create_workspace(
+ WorkspaceCreate(name="Extra", description=""),
+ session=session,
+ auth=auth,
+ )
+
+ assert exc_info.value.status_code == 409
+ assert "at most 2 workspaces" in exc_info.value.detail
diff --git a/surfsense_web/atoms/workspaces/workspace-query.atoms.ts b/surfsense_web/atoms/workspaces/workspace-query.atoms.ts
index 85203cc1d..09e2aa290 100644
--- a/surfsense_web/atoms/workspaces/workspace-query.atoms.ts
+++ b/surfsense_web/atoms/workspaces/workspace-query.atoms.ts
@@ -6,14 +6,23 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
export const activeWorkspaceIdAtom = atom(null);
-export const workspacesQueryParamsAtom = atom({
- skip: 0,
- limit: 10,
- owned_only: false,
+export const workspaceLimitsAtom = atomWithQuery(() => {
+ return {
+ queryKey: cacheKeys.workspaces.limits,
+ staleTime: Infinity,
+ queryFn: async () => {
+ return workspacesApiService.getWorkspaceLimits();
+ },
+ };
});
export const workspacesAtom = atomWithQuery((get) => {
- const queryParams = get(workspacesQueryParamsAtom);
+ const workspaceLimits = get(workspaceLimitsAtom).data;
+ const queryParams: GetWorkspacesRequest["queryParams"] = {
+ skip: 0,
+ ...(workspaceLimits ? { limit: workspaceLimits.max_workspaces_per_user } : {}),
+ owned_only: false,
+ };
return {
queryKey: cacheKeys.workspaces.withQueryParams(queryParams),
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index 682d2923e..a61a1a703 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -14,7 +14,7 @@ import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom";
import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { deleteWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
-import { workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms";
+import { workspaceLimitsAtom, workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms";
import { ActionLogDialog } from "@/components/agent-action-log/action-log-dialog";
import { AnnouncementSpotlight } from "@/components/announcements/AnnouncementSpotlight";
import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog";
@@ -84,6 +84,7 @@ export function LayoutDataProvider({
refetch: refetchWorkspaces,
isSuccess: workspacesLoaded,
} = useAtomValue(workspacesAtom);
+ const { data: workspaceLimits } = useAtomValue(workspaceLimitsAtom);
const { mutateAsync: deleteWorkspace } = useAtomValue(deleteWorkspaceMutationAtom);
const currentThreadState = useAtomValue(currentThreadAtom);
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
@@ -225,6 +226,13 @@ export function LayoutDataProvider({
createdAt: space.created_at,
}));
}, [workspacesData]);
+ const maxWorkspacesPerUser = workspaceLimits?.max_workspaces_per_user;
+ const ownedWorkspaceCount = workspaces.reduce(
+ (count, space) => count + (space.isOwner ? 1 : 0),
+ 0
+ );
+ const isAtWorkspaceLimit =
+ maxWorkspacesPerUser !== undefined && ownedWorkspaceCount >= maxWorkspacesPerUser;
// Find active workspace from list, falling back to the route-scoped detail query.
const activeWorkspace: Workspace | null = useMemo(() => {
@@ -335,8 +343,14 @@ export function LayoutDataProvider({
);
const handleAddWorkspace = useCallback(() => {
+ if (isAtWorkspaceLimit) {
+ toast.error(
+ `Workspace limit reached. You can own at most ${maxWorkspacesPerUser} workspaces.`
+ );
+ return;
+ }
setIsCreateWorkspaceDialogOpen(true);
- }, []);
+ }, [isAtWorkspaceLimit, maxWorkspacesPerUser]);
const setAnnouncementsDialog = useSetAtom(announcementsDialogAtom);
@@ -663,6 +677,8 @@ export function LayoutDataProvider({
onWorkspaceDelete={handleWorkspaceDeleteClick}
onWorkspaceSettings={handleWorkspaceSettings}
onAddWorkspace={handleAddWorkspace}
+ isAtWorkspaceLimit={isAtWorkspaceLimit}
+ maxWorkspacesPerUser={maxWorkspacesPerUser}
workspace={activeWorkspace}
navItems={navItems}
onNavItemClick={handleNavItemClick}
diff --git a/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx b/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx
index 75192e5e2..d8577fe1d 100644
--- a/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx
+++ b/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
+import { toast } from "sonner";
import * as z from "zod";
import { createWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
import { Button } from "@/components/ui/button";
@@ -87,6 +88,7 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia
);
} catch (error) {
console.error("Failed to create workspace:", error);
+ toast.error(error instanceof Error ? error.message : "Failed to create workspace");
setIsSubmitting(false);
}
};
diff --git a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx
index 1f700c89f..0eeac9ca7 100644
--- a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx
+++ b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx
@@ -20,6 +20,8 @@ interface IconRailProps {
onWorkspaceDelete?: (workspace: Workspace) => void;
onWorkspaceSettings?: (workspace: Workspace) => void;
onAddWorkspace: () => void;
+ isAtWorkspaceLimit?: boolean;
+ maxWorkspacesPerUser?: number;
isSingleRailMode?: boolean;
onNewChat?: () => void;
navItems?: NavItem[];
@@ -42,6 +44,8 @@ export function IconRail({
onWorkspaceDelete,
onWorkspaceSettings,
onAddWorkspace,
+ isAtWorkspaceLimit = false,
+ maxWorkspacesPerUser,
isSingleRailMode = false,
onNewChat,
navItems = [],
@@ -78,6 +82,10 @@ export function IconRail({
})),
]
: [];
+ const addWorkspaceLabel =
+ isAtWorkspaceLimit && maxWorkspacesPerUser !== undefined
+ ? `Workspace limit reached: ${maxWorkspacesPerUser}`
+ : "Add workspace";
return (
@@ -99,18 +107,21 @@ export function IconRail({
-
-
- Add workspace
-
+
+
+
+ {addWorkspaceLabel}
+
+
- Add workspace
+ {addWorkspaceLabel}
diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
index 44a2259ee..70bb317dd 100644
--- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
+++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
@@ -95,6 +95,8 @@ interface LayoutShellProps {
onWorkspaceDelete?: (workspace: Workspace) => void;
onWorkspaceSettings?: (workspace: Workspace) => void;
onAddWorkspace: () => void;
+ isAtWorkspaceLimit?: boolean;
+ maxWorkspacesPerUser?: number;
workspace: Workspace | null;
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
@@ -198,6 +200,8 @@ export function LayoutShell({
onWorkspaceDelete,
onWorkspaceSettings,
onAddWorkspace,
+ isAtWorkspaceLimit = false,
+ maxWorkspacesPerUser,
workspace,
navItems,
onNavItemClick,
@@ -280,6 +284,8 @@ export function LayoutShell({
activeWorkspaceId={activeWorkspaceId}
onWorkspaceSelect={onWorkspaceSelect}
onAddWorkspace={onAddWorkspace}
+ isAtWorkspaceLimit={isAtWorkspaceLimit}
+ maxWorkspacesPerUser={maxWorkspacesPerUser}
workspace={workspace}
navItems={navItems}
onNavItemClick={onNavItemClick}
@@ -352,6 +358,8 @@ export function LayoutShell({
onWorkspaceDelete={onWorkspaceDelete}
onWorkspaceSettings={onWorkspaceSettings}
onAddWorkspace={onAddWorkspace}
+ isAtWorkspaceLimit={isAtWorkspaceLimit}
+ maxWorkspacesPerUser={maxWorkspacesPerUser}
isSingleRailMode={false}
user={user}
onUserSettings={onUserSettings}
diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
index de1b08a6a..c84d39891 100644
--- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
@@ -17,6 +17,8 @@ interface MobileSidebarProps {
activeWorkspaceId: number | null;
onWorkspaceSelect: (id: number) => void;
onAddWorkspace: () => void;
+ isAtWorkspaceLimit?: boolean;
+ maxWorkspacesPerUser?: number;
workspace: Workspace | null;
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
@@ -67,6 +69,8 @@ export function MobileSidebar({
onWorkspaceSelect,
onAddWorkspace,
+ isAtWorkspaceLimit = false,
+ maxWorkspacesPerUser,
workspace,
navItems,
onNavItemClick,
@@ -107,6 +111,10 @@ export function MobileSidebar({
onChatSelect(chat);
onOpenChange(false);
};
+ const addWorkspaceLabel =
+ isAtWorkspaceLimit && maxWorkspacesPerUser !== undefined
+ ? `Workspace limit reached: ${maxWorkspacesPerUser}`
+ : "Add workspace";
return (
@@ -136,10 +144,12 @@ export function MobileSidebar({
variant="ghost"
size="icon"
onClick={onAddWorkspace}
+ disabled={isAtWorkspaceLimit}
+ title={addWorkspaceLabel}
className="h-10 w-10 shrink-0 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50"
>
- Add workspace
+ {addWorkspaceLabel}
diff --git a/surfsense_web/contracts/types/workspace.types.ts b/surfsense_web/contracts/types/workspace.types.ts
index a777f810a..8a9502131 100644
--- a/surfsense_web/contracts/types/workspace.types.ts
+++ b/surfsense_web/contracts/types/workspace.types.ts
@@ -29,6 +29,13 @@ export const getWorkspacesRequest = z.object({
export const getWorkspacesResponse = z.array(workspace);
+/**
+ * Workspace limits
+ */
+export const workspaceLimits = z.object({
+ max_workspaces_per_user: z.number(),
+});
+
/**
* Create workspace
*/
@@ -94,6 +101,7 @@ export const leaveWorkspaceResponse = z.object({
// Inferred types
export type Workspace = z.infer;
+export type WorkspaceLimits = z.infer;
export type GetWorkspacesRequest = z.infer;
export type GetWorkspacesResponse = z.infer;
export type CreateWorkspaceRequest = z.infer;
diff --git a/surfsense_web/lib/apis/workspaces-api.service.ts b/surfsense_web/lib/apis/workspaces-api.service.ts
index 8e1494e00..0bd0370a5 100644
--- a/surfsense_web/lib/apis/workspaces-api.service.ts
+++ b/surfsense_web/lib/apis/workspaces-api.service.ts
@@ -19,11 +19,16 @@ import {
updateWorkspaceApiAccessResponse,
updateWorkspaceRequest,
updateWorkspaceResponse,
+ workspaceLimits,
} from "@/contracts/types/workspace.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
class WorkspacesApiService {
+ getWorkspaceLimits = async () => {
+ return baseApiService.get(`/api/v1/workspaces/limits`, workspaceLimits);
+ };
+
/**
* Get a list of workspaces with optional filtering and pagination
*/
diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts
index 13c141f69..9fc2ef2cb 100644
--- a/surfsense_web/lib/query-client/cache-keys.ts
+++ b/surfsense_web/lib/query-client/cache-keys.ts
@@ -49,6 +49,7 @@ export const cacheKeys = {
},
workspaces: {
all: ["workspaces"] as const,
+ limits: ["workspaces", "limits"] as const,
withQueryParams: (queries: GetWorkspacesRequest["queryParams"]) =>
["workspaces", ...stableEntries(queries)] as const,
detail: (workspaceId: string) => ["workspaces", workspaceId] as const,
From 1b62352d0da7b74d451b146b328e0d91d05e2f4c Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 15:55:22 +0530
Subject: [PATCH 075/217] refactor(document-mention-picker): replace
MessageSquare icon with MessageCircleMore for improved visual consistency
---
.../components/new-chat/document-mention-picker.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx
index dca3b5f7f..17a24f799 100644
--- a/surfsense_web/components/new-chat/document-mention-picker.tsx
+++ b/surfsense_web/components/new-chat/document-mention-picker.tsx
@@ -8,7 +8,7 @@ import {
ChevronRight,
Files,
Folder as FolderIcon,
- MessageSquare,
+ MessageCircleMore,
Unplug,
} from "lucide-react";
import {
@@ -146,7 +146,7 @@ export function promoteRecentMention(workspaceId: number, mention: MentionedDocu
function getMentionIcon(mention: MentionedDocumentInfo) {
if (mention.kind === "folder") return ;
- if (mention.kind === "thread") return ;
+ if (mention.kind === "thread") return ;
if (mention.kind === "connector") {
return getConnectorIcon(mention.connector_type, "size-4") ?? ;
}
@@ -517,7 +517,7 @@ export const DocumentMentionPicker = forwardRef<
id: "chats",
label: "Chats",
subtitle: "Reference another conversation",
- icon: ,
+ icon: ,
type: "branch",
value: { kind: "view", view: { kind: "chats" } },
});
@@ -565,7 +565,7 @@ export const DocumentMentionPicker = forwardRef<
id: getMentionDocKey(mention),
label: mention.title,
subtitle: "Chat",
- icon: ,
+ icon: ,
type: "item" as const,
disabled: selectedKeys.has(getMentionDocKey(mention)),
value: { kind: "mention" as const, mention },
@@ -625,7 +625,7 @@ export const DocumentMentionPicker = forwardRef<
id: getMentionDocKey(mention),
label: mention.title,
subtitle: "Chat",
- icon: ,
+ icon: ,
type: "item" as const,
disabled: selectedKeys.has(getMentionDocKey(mention)),
value: { kind: "mention" as const, mention },
From 0ca696f3fca25a1bf021882b57ccf416f4de465f Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:58:18 +0530
Subject: [PATCH 076/217] refactor(tabs): remove legacy tab migration
functionality and associated tests for codebase cleanup
---
surfsense_web/atoms/tabs/migrate-tabs.test.ts | 21 -------------------
surfsense_web/atoms/tabs/migrate-tabs.ts | 19 -----------------
2 files changed, 40 deletions(-)
delete mode 100644 surfsense_web/atoms/tabs/migrate-tabs.test.ts
delete mode 100644 surfsense_web/atoms/tabs/migrate-tabs.ts
diff --git a/surfsense_web/atoms/tabs/migrate-tabs.test.ts b/surfsense_web/atoms/tabs/migrate-tabs.test.ts
deleted file mode 100644
index e0067a4d2..000000000
--- a/surfsense_web/atoms/tabs/migrate-tabs.test.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import assert from "node:assert/strict";
-import { test } from "node:test";
-import { migrateLegacyTabs } from "./migrate-tabs";
-
-// Run with: pnpm exec tsx --test atoms/tabs/migrate-tabs.test.ts
-test("maps legacy searchSpaceId to workspaceId on read", () => {
- const migrated = migrateLegacyTabs({
- tabs: [{ id: "chat-new", type: "chat", searchSpaceId: 7 } as never],
- activeTabId: "chat-new",
- });
- const tab = migrated.tabs[0] as { workspaceId?: number };
- assert.equal(tab.workspaceId, 7);
-});
-
-test("leaves an already-migrated workspaceId untouched", () => {
- const migrated = migrateLegacyTabs({
- tabs: [{ id: "d1", type: "document", workspaceId: 3, searchSpaceId: 9 } as never],
- });
- const tab = migrated.tabs[0] as { workspaceId?: number };
- assert.equal(tab.workspaceId, 3);
-});
diff --git a/surfsense_web/atoms/tabs/migrate-tabs.ts b/surfsense_web/atoms/tabs/migrate-tabs.ts
deleted file mode 100644
index e95a921de..000000000
--- a/surfsense_web/atoms/tabs/migrate-tabs.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * One-time read-migration for persisted tabs: legacy state stored the workspace
- * as `searchSpaceId`. Map it to `workspaceId` on read so already-open tabs keep
- * their workspace association after the rename. Pure + dependency-free so it can
- * be unit-checked without loading the atom module.
- */
-export function migrateLegacyTabs }>(
- state: T
-): T {
- return {
- ...state,
- tabs: state.tabs.map((t) => {
- const legacy = t as { workspaceId?: number; searchSpaceId?: number };
- return legacy.workspaceId === undefined && legacy.searchSpaceId !== undefined
- ? { ...t, workspaceId: legacy.searchSpaceId }
- : t;
- }),
- };
-}
From 2c4ebaeb7f414e7fa1f5b506ba6f775952fe7afc Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:09:50 +0530
Subject: [PATCH 077/217] feat(zero): publish thread title, visibility,
created_by columns
---
.../175_publish_thread_metadata_to_zero.py | 23 +++++++++++++++++++
surfsense_backend/app/zero_publication.py | 3 +++
2 files changed, 26 insertions(+)
create mode 100644 surfsense_backend/alembic/versions/175_publish_thread_metadata_to_zero.py
diff --git a/surfsense_backend/alembic/versions/175_publish_thread_metadata_to_zero.py b/surfsense_backend/alembic/versions/175_publish_thread_metadata_to_zero.py
new file mode 100644
index 000000000..d4d298a4a
--- /dev/null
+++ b/surfsense_backend/alembic/versions/175_publish_thread_metadata_to_zero.py
@@ -0,0 +1,23 @@
+"""publish thread metadata to zero_publication
+
+Revision ID: 175
+Revises: 174
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+from app.zero_publication import apply_publication
+
+revision: str = "175"
+down_revision: str | None = "174"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ apply_publication(op.get_bind())
+
+
+def downgrade() -> None:
+ """No-op. Historical publication shapes are immutable."""
diff --git a/surfsense_backend/app/zero_publication.py b/surfsense_backend/app/zero_publication.py
index c44a29dcd..f6dff6cdf 100644
--- a/surfsense_backend/app/zero_publication.py
+++ b/surfsense_backend/app/zero_publication.py
@@ -60,6 +60,9 @@ AUTOMATION_COLS = [
NEW_CHAT_THREAD_COLS = [
"id",
"workspace_id",
+ "title",
+ "visibility",
+ "created_by_id",
]
# Enough to drive the lifecycle UI by push: status, the reviewable brief, and
From c0492be3ef8d8c8695a6adc69e3c7a04a9a18532 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:09:56 +0530
Subject: [PATCH 078/217] feat(zero): mirror thread
title/visibility/createdById in schema
---
surfsense_web/zero/schema/chat.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/surfsense_web/zero/schema/chat.ts b/surfsense_web/zero/schema/chat.ts
index d42a11848..81673feaa 100644
--- a/surfsense_web/zero/schema/chat.ts
+++ b/surfsense_web/zero/schema/chat.ts
@@ -24,6 +24,9 @@ export const newChatThreadTable = table("new_chat_threads")
.columns({
id: number(),
workspaceId: number().from("workspace_id"),
+ title: string(),
+ visibility: string(),
+ createdById: string().optional().from("created_by_id"),
})
.primaryKey("id");
From c49a3d765d78572a1fe55e80545c0e90fca2a8ed Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:10:31 +0530
Subject: [PATCH 079/217] feat(zero): add threads.byIds query with per-thread
authz
---
surfsense_web/zero/queries/chat.ts | 10 ++++++++++
surfsense_web/zero/queries/index.ts | 3 ++-
2 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/surfsense_web/zero/queries/chat.ts b/surfsense_web/zero/queries/chat.ts
index 40e09a6ee..06969a4e9 100644
--- a/surfsense_web/zero/queries/chat.ts
+++ b/surfsense_web/zero/queries/chat.ts
@@ -29,3 +29,13 @@ export const chatSessionQueries = {
.one()
),
};
+
+export const threadQueries = {
+ byIds: defineQuery(z.object({ ids: z.array(z.number()) }), ({ args: { ids }, ctx }) =>
+ constrainToAllowedSpaces(zql.new_chat_threads, ctx)
+ .where("id", "IN", ids)
+ .where(({ or, cmp }) =>
+ or(cmp("createdById", ctx?.userId ?? ""), cmp("visibility", "SEARCH_SPACE"))
+ )
+ ),
+};
diff --git a/surfsense_web/zero/queries/index.ts b/surfsense_web/zero/queries/index.ts
index 45df8fa98..2e51bf9a8 100644
--- a/surfsense_web/zero/queries/index.ts
+++ b/surfsense_web/zero/queries/index.ts
@@ -1,6 +1,6 @@
import { defineQueries } from "@rocicorp/zero";
import { automationRunQueries } from "./automations";
-import { chatSessionQueries, commentQueries, messageQueries } from "./chat";
+import { chatSessionQueries, commentQueries, messageQueries, threadQueries } from "./chat";
import { connectorQueries, documentQueries } from "./documents";
import { folderQueries } from "./folders";
import { notificationQueries } from "./inbox";
@@ -15,6 +15,7 @@ export const queries = defineQueries({
messages: messageQueries,
comments: commentQueries,
chatSession: chatSessionQueries,
+ threads: threadQueries,
user: userQueries,
automationRuns: automationRunQueries,
podcasts: podcastQueries,
From 88b9632911ee7a85c2b91bd938854f190eaeed21 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:10:39 +0530
Subject: [PATCH 080/217] feat(zero): add documents.byIds query for document
tabs
---
surfsense_web/zero/queries/documents.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/surfsense_web/zero/queries/documents.ts b/surfsense_web/zero/queries/documents.ts
index d2f483989..6c110aee2 100644
--- a/surfsense_web/zero/queries/documents.ts
+++ b/surfsense_web/zero/queries/documents.ts
@@ -9,6 +9,9 @@ export const documentQueries = {
if (!canReadSpace(ctx, workspaceId)) return denySpace(query).orderBy("createdAt", "desc");
return constrainToAllowedSpaces(query, ctx).orderBy("createdAt", "desc");
}),
+ byIds: defineQuery(z.object({ ids: z.array(z.number()) }), ({ args: { ids }, ctx }) =>
+ constrainToAllowedSpaces(zql.documents, ctx).where("id", "IN", ids)
+ ),
};
export const connectorQueries = {
From a8fb161bef97f6e5fc658ebb7a20308abd7d0c20 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:10:47 +0530
Subject: [PATCH 081/217] refactor(tabs): slim Tab to pointer-only state on v2
storage key
---
surfsense_web/atoms/tabs/tabs.atom.ts | 137 ++++++++++++--------------
1 file changed, 61 insertions(+), 76 deletions(-)
diff --git a/surfsense_web/atoms/tabs/tabs.atom.ts b/surfsense_web/atoms/tabs/tabs.atom.ts
index 45e0c098c..fbc546c42 100644
--- a/surfsense_web/atoms/tabs/tabs.atom.ts
+++ b/surfsense_web/atoms/tabs/tabs.atom.ts
@@ -1,22 +1,13 @@
import { atom } from "jotai";
import { atomWithStorage, createJSONStorage } from "jotai/utils";
-import type { ChatVisibility } from "@/lib/chat/thread-persistence";
-import { migrateLegacyTabs } from "./migrate-tabs";
export type TabType = "chat" | "document";
export interface Tab {
id: string;
type: TabType;
- title: string;
- /** For chat tabs */
- chatId?: number | null;
- chatUrl?: string;
- visibility?: ChatVisibility;
- hasComments?: boolean;
- /** For document tabs */
- documentId?: number;
- workspaceId?: number;
+ entityId: number | null;
+ workspaceId: number;
}
interface TabsState {
@@ -27,9 +18,8 @@ interface TabsState {
const INITIAL_CHAT_TAB: Tab = {
id: "chat-new",
type: "chat",
- title: "New Chat",
- chatId: null,
- chatUrl: undefined,
+ entityId: null,
+ workspaceId: 0,
};
const initialState: TabsState = {
@@ -37,23 +27,14 @@ const initialState: TabsState = {
activeTabId: "chat-new",
};
-// Prevent race conditions where route-sync recreates a just-deleted chat tab.
-const deletedChatIdsAtom = atom>(new Set());
-
// Persist tabs in localStorage so they survive a hard refresh and let the user
// keep tabs open across multiple workspaces (browser-like behavior).
const localStorageAdapter = createJSONStorage(
() => (typeof window !== "undefined" ? localStorage : undefined) as Storage
);
-// Wrap getItem in place so the adapter keeps its original (sync) type while
-// migrating legacy persisted state on read.
-const baseGetItem = localStorageAdapter.getItem.bind(localStorageAdapter);
-localStorageAdapter.getItem = (key, initialValue) =>
- migrateLegacyTabs(baseGetItem(key, initialValue));
-
export const tabsStateAtom = atomWithStorage(
- "surfsense:tabs",
+ "surfsense:tabs:v2",
initialState,
localStorageAdapter,
{ getOnInit: true }
@@ -66,11 +47,11 @@ export const activeTabAtom = atom((get) => {
return state.tabs.find((t) => t.id === state.activeTabId) ?? null;
});
-function makeChatTabId(chatId: number | null): string {
+export function makeChatTabId(chatId: number | null): string {
return chatId ? `chat-${chatId}` : "chat-new";
}
-function makeDocumentTabId(documentId: number): string {
+export function makeDocumentTabId(documentId: number): string {
return `doc-${documentId}`;
}
@@ -86,24 +67,12 @@ export const syncChatTabAtom = atom(
set,
{
chatId,
- title,
- chatUrl,
workspaceId,
- visibility,
- hasComments,
}: {
chatId: number | null;
- title?: string;
- chatUrl?: string;
workspaceId: number;
- visibility?: ChatVisibility;
- hasComments?: boolean;
}
) => {
- if (chatId && get(deletedChatIdsAtom).has(chatId)) {
- return;
- }
-
const state = get(tabsStateAtom);
const tabId = makeChatTabId(chatId);
const existing = state.tabs.find((t) => t.id === tabId);
@@ -116,11 +85,8 @@ export const syncChatTabAtom = atom(
t.id === tabId
? {
...t,
- title: title || t.title,
- chatUrl: chatUrl || t.chatUrl,
- workspaceId: workspaceId ?? t.workspaceId,
- ...(visibility !== undefined ? { visibility } : {}),
- ...(hasComments !== undefined ? { hasComments } : {}),
+ entityId: chatId,
+ workspaceId,
}
: t
),
@@ -136,11 +102,13 @@ export const syncChatTabAtom = atom(
set(tabsStateAtom, {
...state,
activeTabId: "chat-new",
- tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, workspaceId, chatUrl } : t)),
+ tabs: state.tabs.map((t) =>
+ t.id === "chat-new" ? { ...t, entityId: null, workspaceId } : t
+ ),
});
} else {
set(tabsStateAtom, {
- tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, workspaceId, chatUrl }],
+ tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, workspaceId }],
activeTabId: "chat-new",
});
}
@@ -152,12 +120,8 @@ export const syncChatTabAtom = atom(
const newTab: Tab = {
id: tabId,
type: "chat",
- title: title || "New Chat",
- chatId,
- chatUrl,
+ entityId: chatId,
workspaceId,
- ...(visibility !== undefined ? { visibility } : {}),
- ...(hasComments !== undefined ? { hasComments } : {}),
};
let updatedTabs: Tab[];
@@ -172,29 +136,26 @@ export const syncChatTabAtom = atom(
}
);
-/** Update the title of the current chat tab (e.g., when a chat gets its first response). */
+/** Promote the lazy "new chat" tab once the server creates its thread. */
export const updateChatTabTitleAtom = atom(
null,
- (get, set, { chatId, title }: { chatId: number; title: string }) => {
+ (get, set, { chatId }: { chatId: number; title?: string }) => {
const state = get(tabsStateAtom);
const tabId = makeChatTabId(chatId);
const hasExactTab = state.tabs.some((t) => t.id === tabId);
- // During lazy thread creation, title updates can arrive before "chat-new"
- // is swapped to chat-{id}. In that case, promote the active "chat-new" tab.
+ // During lazy thread creation, title updates can arrive before "chat-new" is
+ // swapped to chat-{id}. In that case, promote the pointer only; title comes
+ // from Zero.
if (!hasExactTab && state.activeTabId === "chat-new") {
set(tabsStateAtom, {
...state,
activeTabId: tabId,
- tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, id: tabId, chatId, title } : t)),
+ tabs: state.tabs.map((t) =>
+ t.id === "chat-new" ? { ...t, id: tabId, entityId: chatId } : t
+ ),
});
- return;
}
-
- set(tabsStateAtom, {
- ...state,
- tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title } : t)),
- });
}
);
@@ -204,7 +165,7 @@ export const openDocumentTabAtom = atom(
(
get,
set,
- { documentId, workspaceId, title }: { documentId: number; workspaceId: number; title?: string }
+ { documentId, workspaceId }: { documentId: number; workspaceId: number; title?: string }
) => {
const state = get(tabsStateAtom);
const tabId = makeDocumentTabId(documentId);
@@ -214,7 +175,7 @@ export const openDocumentTabAtom = atom(
set(tabsStateAtom, {
...state,
activeTabId: tabId,
- tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title: title || t.title } : t)),
+ tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, workspaceId } : t)),
});
return;
}
@@ -222,8 +183,7 @@ export const openDocumentTabAtom = atom(
const newTab: Tab = {
id: tabId,
type: "document",
- title: title || `Document ${documentId}`,
- documentId,
+ entityId: documentId,
workspaceId,
};
@@ -254,11 +214,12 @@ export const closeTabAtom = atom(null, (get, set, tabId: string) => {
// Don't close the last tab — always keep at least one
if (remaining.length === 0) {
+ const closedTab = state.tabs[idx];
set(tabsStateAtom, {
- tabs: [INITIAL_CHAT_TAB],
+ tabs: [{ ...INITIAL_CHAT_TAB, workspaceId: closedTab.workspaceId }],
activeTabId: "chat-new",
});
- return INITIAL_CHAT_TAB;
+ return { ...INITIAL_CHAT_TAB, workspaceId: closedTab.workspaceId };
}
let newActiveId = state.activeTabId;
@@ -279,18 +240,16 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => {
const idx = state.tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return null;
- const deletedChatIds = get(deletedChatIdsAtom);
- set(deletedChatIdsAtom, new Set([...deletedChatIds, chatId]));
-
const remaining = state.tabs.filter((t) => t.id !== tabId);
// Always keep at least one tab available.
if (remaining.length === 0) {
+ const removedTab = state.tabs[idx];
set(tabsStateAtom, {
- tabs: [INITIAL_CHAT_TAB],
+ tabs: [{ ...INITIAL_CHAT_TAB, workspaceId: removedTab.workspaceId }],
activeTabId: "chat-new",
});
- return INITIAL_CHAT_TAB;
+ return { ...INITIAL_CHAT_TAB, workspaceId: removedTab.workspaceId };
}
let newActiveId = state.activeTabId;
@@ -303,8 +262,34 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => {
return remaining.find((t) => t.id === newActiveId) ?? null;
});
-/** Reset tabs when switching workspaces. */
-export const resetTabsAtom = atom(null, (_get, set) => {
- set(tabsStateAtom, { ...initialState });
- set(deletedChatIdsAtom, new Set());
+/** Remove unresolved chat pointers after Zero confirms the queried rows are complete. */
+export const pruneMissingChatTabsAtom = atom(null, (get, set, missingChatIds: Set) => {
+ if (missingChatIds.size === 0) return;
+
+ const state = get(tabsStateAtom);
+ const firstMissingIdx = state.tabs.findIndex(
+ (t) => t.type === "chat" && t.entityId !== null && missingChatIds.has(t.entityId)
+ );
+ if (firstMissingIdx === -1) return;
+
+ const remaining = state.tabs.filter(
+ (t) => !(t.type === "chat" && t.entityId !== null && missingChatIds.has(t.entityId))
+ );
+
+ if (remaining.length === 0) {
+ set(tabsStateAtom, {
+ tabs: [INITIAL_CHAT_TAB],
+ activeTabId: "chat-new",
+ });
+ return;
+ }
+
+ const activeWasPruned = state.tabs.some(
+ (t) => t.id === state.activeTabId && t.type === "chat" && t.entityId !== null && missingChatIds.has(t.entityId)
+ );
+ const newActiveId = activeWasPruned
+ ? remaining[Math.min(firstMissingIdx, remaining.length - 1)].id
+ : state.activeTabId;
+
+ set(tabsStateAtom, { tabs: remaining, activeTabId: newActiveId });
});
From 873ec0bcd789641ce787f0051695030e289ab827 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:10:53 +0530
Subject: [PATCH 082/217] feat(tabs): add useResolvedTabs join hook resolving
titles from zero
---
surfsense_web/hooks/use-resolved-tabs.test.ts | 53 +++++++
surfsense_web/hooks/use-resolved-tabs.ts | 129 ++++++++++++++++++
2 files changed, 182 insertions(+)
create mode 100644 surfsense_web/hooks/use-resolved-tabs.test.ts
create mode 100644 surfsense_web/hooks/use-resolved-tabs.ts
diff --git a/surfsense_web/hooks/use-resolved-tabs.test.ts b/surfsense_web/hooks/use-resolved-tabs.test.ts
new file mode 100644
index 000000000..34a51df86
--- /dev/null
+++ b/surfsense_web/hooks/use-resolved-tabs.test.ts
@@ -0,0 +1,53 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ getMissingCompleteChatIds,
+ resolveTabPointers,
+ type ResolvedTab,
+} from "./use-resolved-tabs";
+
+// Run with: pnpm exec tsx --test hooks/use-resolved-tabs.test.ts
+test("does not prune unresolved chat tabs before Zero completes", () => {
+ const missing = getMissingCompleteChatIds({
+ tabs: [{ id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 }],
+ threadRows: [],
+ resultType: "unknown",
+ });
+
+ assert.equal(missing.size, 0);
+});
+
+test("prunes unresolved chat tabs only after Zero completes", () => {
+ const missing = getMissingCompleteChatIds({
+ tabs: [
+ { id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 },
+ { id: "chat-43", type: "chat", entityId: 43, workspaceId: 7 },
+ ],
+ threadRows: [{ id: 43, title: "Present", visibility: "PRIVATE" }],
+ resultType: "complete",
+ });
+
+ assert.deepEqual([...missing], [42]);
+});
+
+test("merges pointer tabs with synced row titles", () => {
+ const resolved = resolveTabPointers({
+ tabs: [
+ { id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 },
+ { id: "doc-9", type: "document", entityId: 9, workspaceId: 7 },
+ ],
+ threadRows: [{ id: 42, title: "Live chat title", visibility: "SEARCH_SPACE" }],
+ documentRows: [{ id: 9, title: "Live document title" }],
+ });
+
+ assert.deepEqual(
+ resolved.map((tab): Pick => ({
+ id: tab.id,
+ title: tab.title,
+ })),
+ [
+ { id: "chat-42", title: "Live chat title" },
+ { id: "doc-9", title: "Live document title" },
+ ]
+ );
+});
diff --git a/surfsense_web/hooks/use-resolved-tabs.ts b/surfsense_web/hooks/use-resolved-tabs.ts
new file mode 100644
index 000000000..6b321cbd3
--- /dev/null
+++ b/surfsense_web/hooks/use-resolved-tabs.ts
@@ -0,0 +1,129 @@
+"use client";
+
+import { useQuery } from "@rocicorp/zero/react";
+import { useAtomValue, useSetAtom } from "jotai";
+import { useEffect, useMemo } from "react";
+import { pruneMissingChatTabsAtom, type Tab, tabsAtom } from "@/atoms/tabs/tabs.atom";
+import type { ChatVisibility } from "@/lib/chat/thread-persistence";
+import { queries } from "@/zero/queries";
+
+interface ThreadRow {
+ id: number;
+ title: string;
+ visibility: string;
+}
+
+interface DocumentRow {
+ id: number;
+ title: string;
+}
+
+export interface ResolvedTab extends Tab {
+ title: string;
+ chatUrl?: string;
+ visibility?: ChatVisibility;
+}
+
+function uniqueEntityIds(tabs: Tab[], type: Tab["type"]): number[] {
+ const ids = new Set();
+ for (const tab of tabs) {
+ if (tab.type === type && tab.entityId !== null) {
+ ids.add(tab.entityId);
+ }
+ }
+ return [...ids];
+}
+
+function rowById(rows: readonly T[] | undefined): Map {
+ return new Map((rows ?? []).map((row) => [row.id, row]));
+}
+
+export function getChatUrl(workspaceId: number, threadId: number | null): string {
+ return threadId
+ ? `/dashboard/${workspaceId}/new-chat/${threadId}`
+ : `/dashboard/${workspaceId}/new-chat`;
+}
+
+export function resolveTabPointers({
+ tabs,
+ threadRows,
+ documentRows,
+}: {
+ tabs: Tab[];
+ threadRows?: readonly ThreadRow[];
+ documentRows?: readonly DocumentRow[];
+}): ResolvedTab[] {
+ const threads = rowById(threadRows);
+ const documents = rowById(documentRows);
+
+ return tabs.map((tab) => {
+ if (tab.type === "document") {
+ const title =
+ tab.entityId === null ? "Document" : (documents.get(tab.entityId)?.title ?? `Document ${tab.entityId}`);
+ return { ...tab, title };
+ }
+
+ const row = tab.entityId === null ? undefined : threads.get(tab.entityId);
+ return {
+ ...tab,
+ title: row?.title || (tab.entityId === null ? "New Chat" : `Chat ${tab.entityId}`),
+ chatUrl: getChatUrl(tab.workspaceId, tab.entityId),
+ ...(row?.visibility !== undefined ? { visibility: row.visibility as ChatVisibility } : {}),
+ };
+ });
+}
+
+export function getMissingCompleteChatIds({
+ tabs,
+ threadRows,
+ resultType,
+}: {
+ tabs: Tab[];
+ threadRows?: readonly ThreadRow[];
+ resultType: string;
+}): Set {
+ if (resultType !== "complete") return new Set();
+
+ const threadIds = new Set((threadRows ?? []).map((row) => row.id));
+ return new Set(
+ tabs
+ .filter(
+ (tab): tab is Tab & { type: "chat"; entityId: number } =>
+ tab.type === "chat" && tab.entityId !== null && !threadIds.has(tab.entityId)
+ )
+ .map((tab) => tab.entityId)
+ );
+}
+
+export function useResolvedTabs(): ResolvedTab[] {
+ const tabs = useAtomValue(tabsAtom);
+ const pruneMissingChatTabs = useSetAtom(pruneMissingChatTabsAtom);
+
+ const chatIds = useMemo(() => uniqueEntityIds(tabs, "chat"), [tabs]);
+ const documentIds = useMemo(() => uniqueEntityIds(tabs, "document"), [tabs]);
+ const [threadRows, threadResult] = useQuery(
+ queries.threads.byIds({ ids: chatIds.length > 0 ? chatIds : [-1] })
+ );
+ const [documentRows] = useQuery(
+ queries.documents.byIds({ ids: documentIds.length > 0 ? documentIds : [-1] })
+ );
+
+ const missingChatIds = useMemo(
+ () =>
+ getMissingCompleteChatIds({
+ tabs,
+ threadRows,
+ resultType: threadResult.type,
+ }),
+ [tabs, threadRows, threadResult.type]
+ );
+
+ useEffect(() => {
+ pruneMissingChatTabs(missingChatIds);
+ }, [missingChatIds, pruneMissingChatTabs]);
+
+ return useMemo(
+ () => resolveTabPointers({ tabs, threadRows, documentRows }),
+ [tabs, threadRows, documentRows]
+ );
+}
From e3884399201024200b077396b8f5d29cfd0629ec Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:11:05 +0530
Subject: [PATCH 083/217] refactor(tabs): render TabBar from resolved tabs
---
.../components/layout/ui/tabs/TabBar.tsx | 34 +++++++++++--------
1 file changed, 20 insertions(+), 14 deletions(-)
diff --git a/surfsense_web/components/layout/ui/tabs/TabBar.tsx b/surfsense_web/components/layout/ui/tabs/TabBar.tsx
index 869c9cee2..fc92643b8 100644
--- a/surfsense_web/components/layout/ui/tabs/TabBar.tsx
+++ b/surfsense_web/components/layout/ui/tabs/TabBar.tsx
@@ -3,19 +3,14 @@
import { useAtomValue, useSetAtom } from "jotai";
import { Plus, X } from "lucide-react";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
-import {
- activeTabIdAtom,
- closeTabAtom,
- switchTabAtom,
- type Tab,
- tabsAtom,
-} from "@/atoms/tabs/tabs.atom";
+import { activeTabIdAtom, closeTabAtom, switchTabAtom } from "@/atoms/tabs/tabs.atom";
import { Button } from "@/components/ui/button";
+import { getChatUrl, type ResolvedTab, useResolvedTabs } from "@/hooks/use-resolved-tabs";
import { cn } from "@/lib/utils";
interface TabBarProps {
- onTabSwitch?: (tab: Tab) => void;
- onTabPrefetch?: (tab: Tab) => void;
+ onTabSwitch?: (tab: ResolvedTab) => void;
+ onTabPrefetch?: (tab: ResolvedTab) => void;
onNewChat?: () => void;
leftActions?: React.ReactNode;
rightActions?: React.ReactNode;
@@ -43,7 +38,7 @@ export function TabBar({
rightActions,
className,
}: TabBarProps) {
- const tabs = useAtomValue(tabsAtom);
+ const tabs = useResolvedTabs();
const activeTabId = useAtomValue(activeTabIdAtom);
const switchTab = useSetAtom(switchTabAtom);
const closeTab = useSetAtom(closeTabAtom);
@@ -65,7 +60,7 @@ export function TabBar({
);
const handleTabClick = useCallback(
- (tab: Tab) => {
+ (tab: ResolvedTab) => {
if (tab.id === activeTabId) return;
switchTab(tab.id);
onTabSwitch?.(tab);
@@ -74,7 +69,7 @@ export function TabBar({
);
const handleTabPrefetch = useCallback(
- (tab: Tab) => {
+ (tab: ResolvedTab) => {
if (tab.type === "chat") {
onTabPrefetch?.(tab);
}
@@ -87,10 +82,21 @@ export function TabBar({
e.stopPropagation();
const fallback = closeTab(tabId);
if (fallback) {
- onTabSwitch?.(fallback);
+ const resolvedFallback =
+ tabs.find((tab) => tab.id === fallback.id) ??
+ (fallback.type === "chat"
+ ? {
+ ...fallback,
+ title: "New Chat",
+ chatUrl: getChatUrl(fallback.workspaceId, fallback.entityId),
+ }
+ : undefined);
+ if (resolvedFallback) {
+ onTabSwitch?.(resolvedFallback);
+ }
}
},
- [closeTab, onTabSwitch]
+ [closeTab, onTabSwitch, tabs]
);
// React to tab list growth via a MutationObserver so the scroll catches the
From fa21566cdfa47619540026d94767b6f9c788de86 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:11:18 +0530
Subject: [PATCH 084/217] refactor(tabs): resolve document tab via entityId and
live title
---
.../layout/ui/shell/LayoutShell.tsx | 88 +++++++++++++++++--
1 file changed, 79 insertions(+), 9 deletions(-)
diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
index 70bb317dd..42ef141b2 100644
--- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
+++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
@@ -3,10 +3,11 @@
import { useAtomValue } from "jotai";
import dynamic from "next/dynamic";
import { useMemo, useState } from "react";
-import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
+import { activeTabIdAtom } from "@/atoms/tabs/tabs.atom";
import { Logo } from "@/components/Logo";
import { Spinner } from "@/components/ui/spinner";
import { TooltipProvider } from "@/components/ui/tooltip";
+import { type ResolvedTab, useResolvedTabs } from "@/hooks/use-resolved-tabs";
import { useIsMobile } from "@/hooks/use-mobile";
import { useElectronAPI } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
@@ -123,6 +124,7 @@ interface LayoutShellProps {
defaultCollapsed?: boolean;
isChatPage?: boolean;
isAllChatsPage?: boolean;
+ showTabs?: boolean;
useWorkspacePanel?: boolean;
workspacePanelViewportClassName?: string;
workspacePanelContentClassName?: string;
@@ -130,8 +132,8 @@ interface LayoutShellProps {
className?: string;
notifications?: NotificationsDropdownData;
isLoadingChats?: boolean;
- onTabSwitch?: (tab: Tab) => void;
- onTabPrefetch?: (tab: Tab) => void;
+ onTabSwitch?: (tab: ResolvedTab) => void;
+ onTabPrefetch?: (tab: ResolvedTab) => void;
playgroundSidebar?: React.ReactNode;
initialPlaygroundSidebarCollapsed?: boolean;
}
@@ -141,19 +143,85 @@ function MainContentPanel({
onTabSwitch,
onTabPrefetch,
onNewChat,
+ showTabs = true,
showRightPanelExpandButton = true,
showTopBorder = false,
children,
}: {
isChatPage: boolean;
- onTabSwitch?: (tab: Tab) => void;
- onTabPrefetch?: (tab: Tab) => void;
+ onTabSwitch?: (tab: ResolvedTab) => void;
+ onTabPrefetch?: (tab: ResolvedTab) => void;
onNewChat?: () => void;
+ showTabs?: boolean;
showRightPanelExpandButton?: boolean;
showTopBorder?: boolean;
children: React.ReactNode;
}) {
- const activeTab = useAtomValue(activeTabAtom);
+ if (!showTabs) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+function UntabbedMainContentPanel({
+ isChatPage,
+ showTopBorder,
+ children,
+}: {
+ isChatPage: boolean;
+ showTopBorder: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+function TabbedMainContentPanel({
+ isChatPage,
+ onTabSwitch,
+ onTabPrefetch,
+ onNewChat,
+ showRightPanelExpandButton,
+ showTopBorder,
+ children,
+}: {
+ isChatPage: boolean;
+ onTabSwitch?: (tab: ResolvedTab) => void;
+ onTabPrefetch?: (tab: ResolvedTab) => void;
+ onNewChat?: () => void;
+ showRightPanelExpandButton: boolean;
+ showTopBorder: boolean;
+ children: React.ReactNode;
+}) {
+ const activeTabId = useAtomValue(activeTabIdAtom);
+ const tabs = useResolvedTabs();
+ const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null;
const isDocumentTab = activeTab?.type === "document";
return (
@@ -170,11 +238,11 @@ function MainContentPanel({
- {isDocumentTab && activeTab.documentId && activeTab.workspaceId ? (
+ {isDocumentTab && activeTab.entityId && activeTab.workspaceId ? (
@@ -228,6 +296,7 @@ export function LayoutShell({
defaultCollapsed = false,
isChatPage = false,
isAllChatsPage = false,
+ showTabs = true,
useWorkspacePanel = false,
workspacePanelViewportClassName,
workspacePanelContentClassName,
@@ -476,6 +545,7 @@ export function LayoutShell({
onTabSwitch={onTabSwitch}
onTabPrefetch={onTabPrefetch}
onNewChat={onNewChat}
+ showTabs={showTabs}
showRightPanelExpandButton={!isMacDesktop}
showTopBorder={isMacDesktop}
>
From 1465b907db48d12a3ee79d1b74a1bcd975a9fb03 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:11:23 +0530
Subject: [PATCH 085/217] refactor(tabs): rework fallback navigation for
pointer tabs
---
.../providers/FreeLayoutDataProvider.tsx | 1 +
.../layout/providers/LayoutDataProvider.tsx | 41 +++++++------------
.../layout/ui/sidebar/AllChatsSidebar.tsx | 21 +++-------
3 files changed, 21 insertions(+), 42 deletions(-)
diff --git a/surfsense_web/components/layout/providers/FreeLayoutDataProvider.tsx b/surfsense_web/components/layout/providers/FreeLayoutDataProvider.tsx
index 059872bac..85b5227fa 100644
--- a/surfsense_web/components/layout/providers/FreeLayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/FreeLayoutDataProvider.tsx
@@ -85,6 +85,7 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
onLogout={() => router.push("/register")}
pageUsage={pageUsage}
isChatPage
+ showTabs={false}
isLoadingChats={false}
>
{children}
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index a61a1a703..6858794ca 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -11,7 +11,7 @@ import { toast } from "sonner";
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom";
-import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
+import { removeChatTabAtom, syncChatTabAtom } from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { deleteWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
import { workspaceLimitsAtom, workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms";
@@ -43,6 +43,7 @@ import { Spinner } from "@/components/ui/spinner";
import { useActivateChatThread } from "@/hooks/use-activate-chat-thread";
import { useAnnouncements } from "@/hooks/use-announcements";
import { useInbox } from "@/hooks/use-inbox";
+import { getChatUrl, type ResolvedTab } from "@/hooks/use-resolved-tabs";
import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations";
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
@@ -273,19 +274,11 @@ export function LayoutDataProvider({
// Sync current chat route with tab state
useEffect(() => {
const chatId = currentChatId ?? null;
- const chatUrl = chatId
- ? `/dashboard/${workspaceId}/new-chat/${chatId}`
- : `/dashboard/${workspaceId}/new-chat`;
- const thread = threadsData?.threads?.find((t) => t.id === chatId);
syncChatTab({
chatId,
- // Avoid overwriting live SSE-updated tab titles with fallback values.
- title: chatId ? (thread?.title ?? undefined) : "New Chat",
- chatUrl,
workspaceId: Number(workspaceId),
- ...(thread?.visibility !== undefined ? { visibility: thread.visibility } : {}),
});
- }, [currentChatId, workspaceId, threadsData?.threads, syncChatTab]);
+ }, [currentChatId, workspaceId, syncChatTab]);
const chats = useMemo(() => {
if (!threadsData?.threads) return [];
@@ -440,26 +433,24 @@ export function LayoutDataProvider({
}, [workspaceToLeave, refetchWorkspaces, workspaceId, router, t]);
const handleTabSwitch = useCallback(
- (tab: Tab) => {
+ (tab: ResolvedTab) => {
if (tab.type === "chat") {
activateChatThread({
- id: tab.chatId ?? null,
- title: tab.title,
+ id: tab.entityId,
url: tab.chatUrl,
- workspaceId: tab.workspaceId ?? workspaceId,
+ workspaceId: tab.workspaceId,
...(tab.visibility !== undefined ? { visibility: tab.visibility } : {}),
- ...(tab.hasComments !== undefined ? { hasComments: tab.hasComments } : {}),
});
}
// Document tabs are handled in-place by LayoutShell — no navigation needed
},
- [activateChatThread, workspaceId]
+ [activateChatThread]
);
const handleTabPrefetch = useCallback(
- (tab: Tab) => {
+ (tab: ResolvedTab) => {
if (tab.type === "chat") {
- prefetchChatThread(tab.chatId);
+ prefetchChatThread(tab.entityId);
}
},
[prefetchChatThread]
@@ -573,16 +564,12 @@ export function LayoutDataProvider({
const fallbackTab = removeChatTab(chatToDelete.id);
if (currentChatId === chatToDelete.id) {
resetCurrentThread();
- if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
+ if (fallbackTab?.type === "chat") {
+ const fallbackWorkspaceId = fallbackTab.workspaceId || Number(workspaceId);
activateChatThread({
- id: fallbackTab.chatId ?? null,
- title: fallbackTab.title,
- url: fallbackTab.chatUrl,
- workspaceId: fallbackTab.workspaceId ?? workspaceId,
- ...(fallbackTab.visibility !== undefined ? { visibility: fallbackTab.visibility } : {}),
- ...(fallbackTab.hasComments !== undefined
- ? { hasComments: fallbackTab.hasComments }
- : {}),
+ id: fallbackTab.entityId,
+ url: getChatUrl(fallbackWorkspaceId, fallbackTab.entityId),
+ workspaceId: fallbackWorkspaceId,
});
} else {
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx
index e7cdb7d5e..bc861f443 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx
@@ -41,6 +41,7 @@ import { useActivateChatThread } from "@/hooks/use-activate-chat-thread";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLongPress } from "@/hooks/use-long-press";
import { useIsMobile } from "@/hooks/use-mobile";
+import { getChatUrl } from "@/hooks/use-resolved-tabs";
import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations";
import { fetchThreads, searchThreads, type ThreadListItem } from "@/lib/chat/thread-persistence";
import { formatRelativeDate } from "@/lib/format-date";
@@ -145,22 +146,12 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) {
if (currentChatId === threadId) {
setTimeout(() => {
- if (
- fallbackTab?.type === "chat" &&
- fallbackTab.chatUrl &&
- fallbackTab.chatId !== undefined
- ) {
+ if (fallbackTab?.type === "chat") {
+ const fallbackWorkspaceId = fallbackTab.workspaceId || Number(workspaceId);
activateChatThread({
- id: fallbackTab.chatId ?? null,
- title: fallbackTab.title,
- url: fallbackTab.chatUrl,
- workspaceId: fallbackTab.workspaceId ?? workspaceId,
- ...(fallbackTab.visibility !== undefined
- ? { visibility: fallbackTab.visibility }
- : {}),
- ...(fallbackTab.hasComments !== undefined
- ? { hasComments: fallbackTab.hasComments }
- : {}),
+ id: fallbackTab.entityId,
+ url: getChatUrl(fallbackWorkspaceId, fallbackTab.entityId),
+ workspaceId: fallbackWorkspaceId,
});
return;
}
From 9f1f727d8906adbac3949671a07b0bd6d81ba171 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:11:29 +0530
Subject: [PATCH 086/217] refactor(tabs): drop snapshot args from tab sync
---
.../[workspace_id]/new-chat/[[...chat_id]]/page.tsx | 4 ----
surfsense_web/hooks/use-activate-chat-thread.ts | 6 +-----
2 files changed, 1 insertion(+), 9 deletions(-)
diff --git a/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx
index 22298e574..dff264c43 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx
@@ -299,11 +299,7 @@ export default function NewChatPage() {
setCurrentThread(thread);
syncChatTab({
chatId: thread.id,
- title: thread.title,
- chatUrl: `/dashboard/${thread.workspace_id ?? workspaceId}/new-chat/${thread.id}`,
workspaceId: thread.workspace_id ?? workspaceId,
- visibility: thread.visibility,
- hasComments: thread.has_comments ?? false,
});
}
}, [activeThreadId, workspaceId, syncChatTab, threadDetailQuery.data]);
diff --git a/surfsense_web/hooks/use-activate-chat-thread.ts b/surfsense_web/hooks/use-activate-chat-thread.ts
index 2b68ef76f..c0712908f 100644
--- a/surfsense_web/hooks/use-activate-chat-thread.ts
+++ b/surfsense_web/hooks/use-activate-chat-thread.ts
@@ -45,17 +45,13 @@ export function useActivateChatThread() {
);
const activateChatThread = useCallback(
- ({ id, title, url, workspaceId, visibility, hasComments }: ActivateChatThreadInput) => {
+ ({ id, url, workspaceId, visibility, hasComments }: ActivateChatThreadInput) => {
const numericWorkspaceId = getWorkspaceId(workspaceId);
const chatUrl = url ?? getChatUrl(workspaceId, id);
syncChatTab({
chatId: id,
- title: id ? title : (title ?? "New Chat"),
- chatUrl,
workspaceId: numericWorkspaceId,
- ...(visibility !== undefined ? { visibility } : {}),
- ...(hasComments !== undefined ? { hasComments } : {}),
});
setCurrentThreadMetadata({
From 7b4e4792bffa43b455e3e99fe23f4da5d47f209e Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:16:09 +0530
Subject: [PATCH 087/217] fix(chat): update default chat title to "New Chat" in
LayoutDataProvider and useResolvedTabs
---
.../components/layout/providers/LayoutDataProvider.tsx | 2 +-
surfsense_web/hooks/use-resolved-tabs.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index 6858794ca..511c095e5 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -285,7 +285,7 @@ export function LayoutDataProvider({
return threadsData.threads.map
((thread) => ({
id: thread.id,
- name: thread.title || `Chat ${thread.id}`,
+ name: thread.title || "New Chat",
url: `/dashboard/${workspaceId}/new-chat/${thread.id}`,
visibility: thread.visibility,
isOwnThread: thread.is_own_thread,
diff --git a/surfsense_web/hooks/use-resolved-tabs.ts b/surfsense_web/hooks/use-resolved-tabs.ts
index 6b321cbd3..1b072857a 100644
--- a/surfsense_web/hooks/use-resolved-tabs.ts
+++ b/surfsense_web/hooks/use-resolved-tabs.ts
@@ -66,7 +66,7 @@ export function resolveTabPointers({
const row = tab.entityId === null ? undefined : threads.get(tab.entityId);
return {
...tab,
- title: row?.title || (tab.entityId === null ? "New Chat" : `Chat ${tab.entityId}`),
+ title: row?.title || "New Chat",
chatUrl: getChatUrl(tab.workspaceId, tab.entityId),
...(row?.visibility !== undefined ? { visibility: row.visibility as ChatVisibility } : {}),
};
From c415e68bfc853e34b318d0479963fba6727095ef Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 17 Jul 2026 09:41:29 +0530
Subject: [PATCH 088/217] refactor(chat): remove loading component and enhance
footer visibility logic in ChatViewport
---
.../[workspace_id]/new-chat/loading.tsx | 62 -------------------
.../components/assistant-ui/chat-viewport.tsx | 14 ++++-
.../components/assistant-ui/thread.tsx | 5 +-
3 files changed, 15 insertions(+), 66 deletions(-)
delete mode 100644 surfsense_web/app/dashboard/[workspace_id]/new-chat/loading.tsx
diff --git a/surfsense_web/app/dashboard/[workspace_id]/new-chat/loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/new-chat/loading.tsx
deleted file mode 100644
index 108671662..000000000
--- a/surfsense_web/app/dashboard/[workspace_id]/new-chat/loading.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { Skeleton } from "@/components/ui/skeleton";
-
-export default function Loading() {
- return (
-
-
-
-
- {/* User message */}
-
-
-
-
- {/* Assistant message */}
-
-
-
-
-
-
- {/* User message */}
-
-
-
-
- {/* Assistant message */}
-
-
-
-
-
-
- {/* User message */}
-
-
-
-
-
- {/* Input bar */}
-
-
-
- );
-}
diff --git a/surfsense_web/components/assistant-ui/chat-viewport.tsx b/surfsense_web/components/assistant-ui/chat-viewport.tsx
index 83308b642..cb0c57442 100644
--- a/surfsense_web/components/assistant-ui/chat-viewport.tsx
+++ b/surfsense_web/components/assistant-ui/chat-viewport.tsx
@@ -22,9 +22,19 @@ const ChatScrollToBottom: FC = () => (
export interface ChatViewportProps {
children: ReactNode;
footer?: ReactNode;
+ /**
+ * Keep the footer (composer) pinned even when the thread has no messages —
+ * needed while an existing thread's messages are still loading, so the
+ * bottom composer stays visible above the loading skeleton.
+ */
+ footerAlwaysVisible?: boolean;
}
-export const ChatViewport: FC = ({ children, footer }) => (
+export const ChatViewport: FC = ({
+ children,
+ footer,
+ footerAlwaysVisible = false,
+}) => (
= ({ children, footer }) => (
/>
{children}
{footer ? (
- !thread.isEmpty}>
+ footerAlwaysVisible || !thread.isEmpty}>
= ({
}}
>
hasActiveThread || !thread.isEmpty}>
+ <>
-
+ >
}
>
!hasActiveThread && thread.isEmpty}>
From 3ec25bd9a49b7f83a9f63881830c1a872f0bbe54 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 17 Jul 2026 09:41:40 +0530
Subject: [PATCH 089/217] refactor(chat): enhance Composer component to include
active thread state and update footer logic
---
surfsense_web/components/assistant-ui/thread.tsx | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx
index fc8118fe7..d10841c5c 100644
--- a/surfsense_web/components/assistant-ui/thread.tsx
+++ b/surfsense_web/components/assistant-ui/thread.tsx
@@ -173,7 +173,7 @@ const ThreadContent: FC = ({
footer={
<>
-
+
>
}
>
@@ -531,10 +531,14 @@ const ChatUnavailableNotice: FC<{ workspaceId: number; canConfigure: boolean }>
};
interface ComposerProps {
+ hasActiveThread?: boolean;
isLoadingMessages?: boolean;
}
-const Composer: FC = ({ isLoadingMessages = false }) => {
+const Composer: FC = ({
+ hasActiveThread = false,
+ isLoadingMessages = false,
+}) => {
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const setSubmittedMentions = useSetAtom(submittedMentionsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
@@ -1068,7 +1072,7 @@ const Composer: FC = ({ isLoadingMessages = false }) => {
/>
{!isLoadingMessages && isThreadEmpty && isComposerInputEmpty ? (
From 5cc05d90f0de4e4c093f0917381d726817f7dc4e Mon Sep 17 00:00:00 2001
From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com>
Date: Sat, 18 Jul 2026 17:46:31 -0700
Subject: [PATCH 090/217] fix: avoid blocking audio writes
TTS responses were written synchronously from the async presentation pipeline, blocking concurrent requests. Offload byte writes to a worker thread and add a regression test that keeps the event loop responsive.
---
.../app/agents/video_presentation/nodes.py | 4 +--
surfsense_backend/app/utils/file_io.py | 7 +++++
.../agents/video_presentation/test_file_io.py | 29 +++++++++++++++++++
3 files changed, 38 insertions(+), 2 deletions(-)
create mode 100644 surfsense_backend/app/utils/file_io.py
create mode 100644 surfsense_backend/tests/unit/agents/video_presentation/test_file_io.py
diff --git a/surfsense_backend/app/agents/video_presentation/nodes.py b/surfsense_backend/app/agents/video_presentation/nodes.py
index dca89059f..e52d1f4ce 100644
--- a/surfsense_backend/app/agents/video_presentation/nodes.py
+++ b/surfsense_backend/app/agents/video_presentation/nodes.py
@@ -17,6 +17,7 @@ from app.config import config as app_config
from app.services.kokoro_tts_service import get_kokoro_tts_service
from app.services.llm_service import get_agent_llm
from app.utils.content_utils import extract_text_content, strip_markdown_fences
+from app.utils.file_io import write_bytes
from .configuration import Configuration
from .prompts import (
@@ -137,8 +138,7 @@ async def create_slide_audio(state: State, config: RunnableConfig) -> dict[str,
kwargs["api_base"] = app_config.TTS_SERVICE_API_BASE
response = await aspeech(**kwargs)
- with open(chunk_path, "wb") as f:
- f.write(response.content)
+ await write_bytes(chunk_path, response.content)
return chunk_path
diff --git a/surfsense_backend/app/utils/file_io.py b/surfsense_backend/app/utils/file_io.py
new file mode 100644
index 000000000..2e5700955
--- /dev/null
+++ b/surfsense_backend/app/utils/file_io.py
@@ -0,0 +1,7 @@
+import asyncio
+from pathlib import Path
+
+
+async def write_bytes(path: str, content: bytes) -> None:
+ """Write bytes without blocking the event loop."""
+ await asyncio.to_thread(Path(path).write_bytes, content)
diff --git a/surfsense_backend/tests/unit/agents/video_presentation/test_file_io.py b/surfsense_backend/tests/unit/agents/video_presentation/test_file_io.py
new file mode 100644
index 000000000..c85cef19c
--- /dev/null
+++ b/surfsense_backend/tests/unit/agents/video_presentation/test_file_io.py
@@ -0,0 +1,29 @@
+import asyncio
+import threading
+
+import pytest
+
+from app.utils.file_io import write_bytes
+
+pytestmark = pytest.mark.unit
+
+
+@pytest.mark.asyncio
+async def test_write_bytes_does_not_block_event_loop(monkeypatch, tmp_path):
+ write_started = threading.Event()
+ release_write = threading.Event()
+
+ def blocking_write(_path, _content):
+ write_started.set()
+ release_write.wait(timeout=1)
+ return 5
+
+ monkeypatch.setattr("pathlib.Path.write_bytes", blocking_write)
+
+ task = asyncio.create_task(write_bytes(str(tmp_path / "audio.mp3"), b"audio"))
+ while not write_started.is_set():
+ await asyncio.sleep(0)
+
+ assert not task.done()
+ release_write.set()
+ await task
From 0d2c8df5bd6b74a2a1c4e9d1ee97dddeae38e39a Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 08:19:21 +0200
Subject: [PATCH 091/217] feat(walmart): add billing units and rate config
---
surfsense_backend/app/capabilities/core/billing.py | 4 ++++
surfsense_backend/app/capabilities/core/types.py | 2 ++
surfsense_backend/app/config/__init__.py | 6 ++++++
3 files changed, 12 insertions(+)
diff --git a/surfsense_backend/app/capabilities/core/billing.py b/surfsense_backend/app/capabilities/core/billing.py
index c2c7f6b5b..1d08a8bc7 100644
--- a/surfsense_backend/app/capabilities/core/billing.py
+++ b/surfsense_backend/app/capabilities/core/billing.py
@@ -41,6 +41,8 @@ _PLATFORM_RATE_KEYS: dict[BillingUnit, str] = {
BillingUnit.TIKTOK_VIDEO: "TIKTOK_MICROS_PER_VIDEO",
BillingUnit.TIKTOK_USER: "TIKTOK_MICROS_PER_USER",
BillingUnit.TIKTOK_COMMENT: "TIKTOK_MICROS_PER_COMMENT",
+ BillingUnit.WALMART_PRODUCT: "WALMART_MICROS_PER_PRODUCT",
+ BillingUnit.WALMART_REVIEW: "WALMART_MICROS_PER_REVIEW",
}
@@ -63,6 +65,8 @@ _UNIT_NOUNS: dict[BillingUnit, str] = {
BillingUnit.TIKTOK_VIDEO: "video",
BillingUnit.TIKTOK_USER: "profile",
BillingUnit.TIKTOK_COMMENT: "comment",
+ BillingUnit.WALMART_PRODUCT: "product",
+ BillingUnit.WALMART_REVIEW: "review",
}
diff --git a/surfsense_backend/app/capabilities/core/types.py b/surfsense_backend/app/capabilities/core/types.py
index 2e8d3b6cc..ba63a0908 100644
--- a/surfsense_backend/app/capabilities/core/types.py
+++ b/surfsense_backend/app/capabilities/core/types.py
@@ -31,6 +31,8 @@ class BillingUnit(StrEnum):
TIKTOK_VIDEO = "tiktok_video"
TIKTOK_USER = "tiktok_user"
TIKTOK_COMMENT = "tiktok_comment"
+ WALMART_PRODUCT = "walmart_product"
+ WALMART_REVIEW = "walmart_review"
class BillableInput(Protocol):
diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py
index 20497bb15..d90aa2542 100644
--- a/surfsense_backend/app/config/__init__.py
+++ b/surfsense_backend/app/config/__init__.py
@@ -736,6 +736,12 @@ class Config:
# Comments are the cheapest per-item TikTok data, matching the per-comment
# market (and YouTube's comment meter).
TIKTOK_MICROS_PER_COMMENT = int(os.getenv("TIKTOK_MICROS_PER_COMMENT", "1500"))
+ # Walmart products come from server-rendered JSON behind residential proxies,
+ # priced alongside Amazon's per-product meter.
+ WALMART_MICROS_PER_PRODUCT = int(os.getenv("WALMART_MICROS_PER_PRODUCT", "3500"))
+ # Reviews are 10 per page (many light requests per product), priced on the
+ # cheaper per-review market like the Google Maps review meter.
+ WALMART_MICROS_PER_REVIEW = int(os.getenv("WALMART_MICROS_PER_REVIEW", "1500"))
# Retry an empty listing draw on a fresh rotating IP. Set to 1 for a static
# proxy, where every retry re-hits the same exit.
TIKTOK_LISTING_MAX_ATTEMPTS = int(os.getenv("TIKTOK_LISTING_MAX_ATTEMPTS", "3"))
From a72cc3436cff02a341c1059c96b071a1b06242ec Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 08:19:21 +0200
Subject: [PATCH 092/217] feat(walmart): add product and review schemas
---
.../proprietary/platforms/walmart/schemas.py | 188 ++++++++++++++++++
1 file changed, 188 insertions(+)
create mode 100644 surfsense_backend/app/proprietary/platforms/walmart/schemas.py
diff --git a/surfsense_backend/app/proprietary/platforms/walmart/schemas.py b/surfsense_backend/app/proprietary/platforms/walmart/schemas.py
new file mode 100644
index 000000000..ea25f2eff
--- /dev/null
+++ b/surfsense_backend/app/proprietary/platforms/walmart/schemas.py
@@ -0,0 +1,188 @@
+# ruff: noqa: N815
+"""Input/output models for the public Walmart scraper.
+
+Two verbs share this module: ``walmart.scrape`` (products + listings) and
+``walmart.reviews`` (deep paginated reviews). Walmart is a Next.js app that
+ships its data as JSON in a ``', re.DOTALL)
+_APP_DATA_RE = re.compile(r'', re.DOTALL)
+
+
+def extract_next_data(html: str | None) -> dict[str, Any] | None:
+ """Return the parsed Next.js state object, or ``None`` when absent/invalid.
+
+ Tries ``__NEXT_DATA__`` first, then the ``__APP_DATA__`` fallback so a single
+ Walmart layout experiment does not blank the whole extractor.
+ """
+ if not html:
+ return None
+ for pattern in (_NEXT_DATA_RE, _APP_DATA_RE):
+ match = pattern.search(html)
+ if not match:
+ continue
+ try:
+ data = json.loads(match.group(1))
+ except (ValueError, TypeError):
+ logger.warning("Walmart hidden JSON present but did not parse")
+ continue
+ if isinstance(data, dict):
+ return data
+ return None
+
+
+def dig(obj: Any, *keys: str | int) -> Any:
+ """Walk nested dict/list keys, returning ``None`` on any miss.
+
+ Tolerates the layout drift between Walmart's ``initialData`` variants without
+ a cascade of ``if key in ...`` guards at every call site.
+ """
+ current = obj
+ for key in keys:
+ if isinstance(key, int):
+ if not isinstance(current, list) or not -len(current) <= key < len(current):
+ return None
+ current = current[key]
+ else:
+ if not isinstance(current, dict) or key not in current:
+ return None
+ current = current[key]
+ return current
+
+
+def initial_data(next_data: dict[str, Any]) -> dict[str, Any] | None:
+ """The ``props.pageProps.initialData`` node shared by every page type."""
+ node = dig(next_data, "props", "pageProps", "initialData")
+ return node if isinstance(node, dict) else None
From 34b6885ba220988140135f96e38d33f5cf1afc26 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 08:19:21 +0200
Subject: [PATCH 095/217] feat(walmart): add fetch with proxy rotation and
block detection
---
.../proprietary/platforms/walmart/fetch.py | 171 ++++++++++++++++++
1 file changed, 171 insertions(+)
create mode 100644 surfsense_backend/app/proprietary/platforms/walmart/fetch.py
diff --git a/surfsense_backend/app/proprietary/platforms/walmart/fetch.py b/surfsense_backend/app/proprietary/platforms/walmart/fetch.py
new file mode 100644
index 000000000..851b6a674
--- /dev/null
+++ b/surfsense_backend/app/proprietary/platforms/walmart/fetch.py
@@ -0,0 +1,171 @@
+"""Network access for public Walmart pages.
+
+Mirrors the Amazon fetch layer: every request goes through the configured
+residential proxy (pinned to the US, since Walmart geo-locks inventory), and any
+response that looks like an anti-bot interstitial is retried on a fresh proxy
+exit. Ordinary HTTP failures are returned to the caller for domain-specific
+handling.
+
+Walmart runs Akamai (edge/TLS) + PerimeterX/HUMAN (behavioral JS). Two Walmart
+specifics differ from Amazon:
+
+* Walmart serves CAPTCHA with an HTTP ``200`` body ("Robot or human?"), so block
+ detection scans the body, never the status alone.
+* ``412`` is PerimeterX's rejection code and is treated as blocked → rotate.
+
+``ponytail:`` MVP hits only the server-rendered ``__NEXT_DATA__`` pages, which
+TLS impersonation + residential proxies clear without seeding PerimeterX cookies.
+If block rates on those pages climb, the upgrade path is a warmed sticky session
+(seed ``_px3``/``_pxhd``/``ACID`` from a homepage fetch, reuse exit + cookies) —
+the same shape as Amazon's ``get_location_session``.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field
+from typing import Any
+
+from scrapling.fetchers import AsyncFetcher
+
+from app.utils.proxy import get_geo_proxy_url, get_sticky_proxy_url
+
+logger = logging.getLogger(__name__)
+
+_MAX_IP_ATTEMPTS = 6
+_REQUEST_TIMEOUT_S = 30
+_HEADERS = {
+ "Accept-Language": "en-US,en;q=0.9",
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+}
+_BLOCK_MARKERS = (
+ "robot or human",
+ "px-captcha",
+ "/blocked",
+ "verify you are a human",
+ "access to this page has been denied",
+)
+
+
+@dataclass(frozen=True)
+class FetchResult:
+ """The response details needed by scraper flows."""
+
+ status: int
+ html: str
+ url: str
+ cookies: dict[str, str]
+ headers: dict[str, str] = field(default_factory=dict)
+
+
+async def gather_bounded[T](
+ factories: list[Callable[[], Awaitable[T]]], *, concurrency: int
+) -> list[T]:
+ """Run async factories concurrently while preserving input order."""
+ if not factories:
+ return []
+ semaphore = asyncio.Semaphore(max(1, concurrency))
+
+ async def run(factory: Callable[[], Awaitable[T]]) -> T:
+ async with semaphore:
+ return await factory()
+
+ return await asyncio.gather(*(run(factory) for factory in factories))
+
+
+def is_blocked(
+ html: str | None, status: int, headers: dict[str, str] | None = None
+) -> bool:
+ """Return whether a response is a Walmart anti-bot interstitial.
+
+ ``412`` is PerimeterX's rejection; ``429``/``503`` are throttles. Walmart also
+ serves CAPTCHA with a ``200`` body, so the body is scanned regardless of
+ status.
+ """
+ if status in {412, 429, 503}:
+ return True
+ text = (html or "")[:200_000].lower()
+ return any(marker in text for marker in _BLOCK_MARKERS)
+
+
+def _response_url(page: Any, fallback: str) -> str:
+ value = getattr(page, "url", None)
+ return str(value) if value else fallback
+
+
+def _response_cookies(page: Any) -> dict[str, str]:
+ cookies = getattr(page, "cookies", None)
+ return dict(cookies) if isinstance(cookies, dict) else {}
+
+
+def _response_headers(page: Any) -> dict[str, str]:
+ headers = getattr(page, "headers", None)
+ return dict(headers) if isinstance(headers, dict) else {}
+
+
+def _selected_proxy(
+ proxy: str | None, country: str, attempt: int, url: str
+) -> str | None:
+ if proxy is not None:
+ return proxy
+ if attempt > 1:
+ session_id = f"walmart-{attempt}-{abs(hash((url, time.time_ns()))):x}"
+ return get_sticky_proxy_url(session_id, country)
+ return get_geo_proxy_url(country)
+
+
+async def fetch_page(
+ url: str,
+ *,
+ cookies: dict[str, str] | None = None,
+ proxy: str | None = None,
+ country: str = "us",
+ rotate_on_block: bool = True,
+) -> FetchResult | None:
+ """Fetch a page and retry blocked responses with fresh proxy exits."""
+ attempts = _MAX_IP_ATTEMPTS if rotate_on_block else 1
+ for attempt in range(1, attempts + 1):
+ selected_proxy = _selected_proxy(proxy, country, attempt, url)
+ started = time.perf_counter()
+ try:
+ page = await AsyncFetcher.get(
+ url,
+ headers={**_HEADERS},
+ cookies=cookies or {},
+ proxy=selected_proxy,
+ stealthy_headers=True,
+ timeout=_REQUEST_TIMEOUT_S,
+ )
+ except Exception as exc:
+ logger.warning("Walmart request failed for %s: %s", url, exc)
+ if proxy is not None:
+ return None
+ continue
+
+ status = int(getattr(page, "status", 0) or 0)
+ html = getattr(page, "html_content", None) or ""
+ response_headers = _response_headers(page)
+ logger.info(
+ "[walmart][perf] status=%s attempt=%s fetch_ms=%.1f url=%s",
+ status,
+ attempt,
+ (time.perf_counter() - started) * 1000,
+ url,
+ )
+ if rotate_on_block and is_blocked(html, status, response_headers):
+ logger.info(
+ "Walmart blocked proxy attempt %s/%s for %s", attempt, attempts, url
+ )
+ continue
+ return FetchResult(
+ status=status,
+ html=html,
+ url=_response_url(page, url),
+ cookies=_response_cookies(page),
+ headers=response_headers,
+ )
+ logger.warning("Walmart exhausted %s proxy attempts for %s", attempts, url)
+ return None
From 661bb8c1872210bd28bc04a2efad83cad8307717 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 08:19:22 +0200
Subject: [PATCH 096/217] feat(walmart): add JSON parsers
---
.../proprietary/platforms/walmart/parsers.py | 231 ++++++++++++++++++
1 file changed, 231 insertions(+)
create mode 100644 surfsense_backend/app/proprietary/platforms/walmart/parsers.py
diff --git a/surfsense_backend/app/proprietary/platforms/walmart/parsers.py b/surfsense_backend/app/proprietary/platforms/walmart/parsers.py
new file mode 100644
index 000000000..9a0d070a1
--- /dev/null
+++ b/surfsense_backend/app/proprietary/platforms/walmart/parsers.py
@@ -0,0 +1,231 @@
+"""Pure parsers for Walmart's hidden ``__NEXT_DATA__`` JSON.
+
+Product, listing, and review data all live in the same Next.js state tree; these
+functions navigate it defensively (via :func:`.next_data.dig`) and normalize the
+fields into the stable output shape. Missing sections yield ``None``/``[]`` so an
+isolated schema change never discards an otherwise usable record.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+from urllib.parse import urljoin
+
+from .next_data import dig, initial_data
+
+_WALMART_ORIGIN = "https://www.walmart.com"
+
+
+# --------------------------------------------------------------------------- #
+# Shared helpers. #
+# --------------------------------------------------------------------------- #
+
+
+def _price(node: Any) -> dict[str, Any] | None:
+ """Normalize a Walmart price node into ``{value, currency}``."""
+ if not isinstance(node, dict):
+ return None
+ value = node.get("price")
+ currency = node.get("currencyUnit")
+ if value is None and currency is None:
+ return None
+ return {"value": value, "currency": currency}
+
+
+def _seller(raw: dict[str, Any]) -> dict[str, Any] | None:
+ name = raw.get("sellerName") or raw.get("sellerDisplayName")
+ seller_id = raw.get("sellerId")
+ if not name and not seller_id:
+ return None
+ is_walmart = bool(name) and "walmart" in name.lower()
+ return {
+ "id": seller_id,
+ "name": name,
+ "type": "WALMART" if is_walmart else "MARKETPLACE",
+ }
+
+
+def _absolute(url: str | None) -> str | None:
+ return urljoin(_WALMART_ORIGIN, url) if url else None
+
+
+# --------------------------------------------------------------------------- #
+# Listing / search cards. #
+# --------------------------------------------------------------------------- #
+
+
+def _listing_card(raw: dict[str, Any]) -> dict[str, Any] | None:
+ """Normalize one search/category result item into a product card."""
+ item_id = raw.get("usItemId") or raw.get("id")
+ name = raw.get("name")
+ if not item_id or not name:
+ return None
+ price_info = raw.get("priceInfo") or {}
+ availability = raw.get("availabilityStatusV2") or {}
+ image = raw.get("imageInfo") or {}
+ return {
+ "usItemId": str(item_id),
+ "name": name,
+ "brand": raw.get("brand"),
+ "url": _absolute(raw.get("canonicalUrl")),
+ "price": _price(price_info.get("currentPrice"))
+ or ({"value": raw.get("price")} if raw.get("price") is not None else None),
+ "listPrice": _price(price_info.get("wasPrice")),
+ "stars": raw.get("averageRating"),
+ "reviewsCount": raw.get("numberOfReviews"),
+ "seller": _seller(raw),
+ "availabilityStatus": availability.get("value")
+ or ("OUT_OF_STOCK" if raw.get("isOutOfStock") else None),
+ "inStock": (availability.get("value") == "IN_STOCK")
+ if availability.get("value")
+ else (raw.get("isOutOfStock") is False if "isOutOfStock" in raw else None),
+ "thumbnailImage": image.get("thumbnailUrl") or raw.get("image"),
+ "sponsored": raw.get("isSponsoredFlag"),
+ }
+
+
+def parse_listing_page(next_data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Extract normalized product cards from a search/category/browse page."""
+ data = initial_data(next_data)
+ if data is None:
+ return []
+ stacks = dig(data, "searchResult", "itemStacks")
+ if not isinstance(stacks, list):
+ return []
+ cards: list[dict[str, Any]] = []
+ seen: set[str] = set()
+ for stack in stacks:
+ for raw in (stack or {}).get("items") or []:
+ if not isinstance(raw, dict) or raw.get("__typename") not in (
+ None,
+ "Product",
+ ):
+ continue
+ card = _listing_card(raw)
+ if card and card["usItemId"] not in seen:
+ seen.add(card["usItemId"])
+ cards.append(card)
+ return cards
+
+
+# --------------------------------------------------------------------------- #
+# Product detail. #
+# --------------------------------------------------------------------------- #
+
+
+def _images(image_info: dict[str, Any]) -> list[str]:
+ images: list[str] = []
+ for entry in image_info.get("allImages") or []:
+ url = entry.get("url") if isinstance(entry, dict) else None
+ if url and url not in images:
+ images.append(url)
+ return images
+
+
+def _reviews_sample(
+ reviews: dict[str, Any] | None, limit: int = 10
+) -> dict[str, Any] | None:
+ if not isinstance(reviews, dict):
+ return None
+ customer = reviews.get("customerReviews") or []
+ return {
+ "averageOverallRating": reviews.get("averageOverallRating"),
+ "totalReviewCount": reviews.get("totalReviewCount"),
+ "aspects": reviews.get("aspects") or [],
+ "topReviews": [
+ normalize_review(r) for r in customer[:limit] if isinstance(r, dict)
+ ],
+ }
+
+
+def parse_product(
+ next_data: dict[str, Any], *, url: str, include_reviews_sample: bool = True
+) -> dict[str, Any]:
+ """Extract normalized product fields from a product detail page."""
+ data = initial_data(next_data)
+ product = dig(data, "data", "product") if data else None
+ if not isinstance(product, dict):
+ return {}
+ idml = dig(data, "data", "idml") if data else None
+ reviews = dig(data, "data", "reviews") if data else None
+ price_info = product.get("priceInfo") or {}
+ image_info = product.get("imageInfo") or {}
+ availability = product.get("availabilityStatus")
+
+ fields: dict[str, Any] = {
+ "usItemId": str(product.get("usItemId") or product.get("id") or "") or None,
+ "name": product.get("name"),
+ "brand": product.get("brand"),
+ "url": url,
+ "price": _price(price_info.get("currentPrice")),
+ "listPrice": _price(price_info.get("wasPrice")),
+ "currency": dig(price_info, "currentPrice", "currencyUnit"),
+ "availabilityStatus": availability,
+ "inStock": availability == "IN_STOCK" if availability else None,
+ "stars": product.get("averageRating"),
+ "reviewsCount": product.get("numberOfReviews"),
+ "seller": _seller(product),
+ "manufacturerName": product.get("manufacturerName"),
+ "shortDescription": product.get("shortDescription"),
+ "longDescription": (idml or {}).get("longDescription")
+ if isinstance(idml, dict)
+ else None,
+ "thumbnailImage": image_info.get("thumbnailUrl"),
+ "images": _images(image_info),
+ "category": dig(product, "category", "path"),
+ "variants": product.get("variantCriteria") or [],
+ }
+ if include_reviews_sample:
+ fields["reviewsSample"] = _reviews_sample(reviews)
+ return {key: value for key, value in fields.items() if value is not None}
+
+
+# --------------------------------------------------------------------------- #
+# Reviews (deep pagination). #
+# --------------------------------------------------------------------------- #
+
+
+def normalize_review(raw: dict[str, Any]) -> dict[str, Any]:
+ """Normalize one Walmart ``customerReviews`` record into a ``ReviewItem`` dict."""
+ badges = raw.get("badges") or []
+ verified = any(
+ isinstance(b, dict) and b.get("id") == "VerifiedPurchaser" for b in badges
+ )
+ photos = raw.get("photos") or raw.get("media") or []
+ images: list[str] = []
+ for photo in photos:
+ if not isinstance(photo, dict):
+ continue
+ url = photo.get("normalUrl") or dig(photo, "sizes", "normal", "url")
+ if url and url not in images:
+ images.append(url)
+ responses = raw.get("clientResponses") or []
+ seller_response = None
+ if responses and isinstance(responses[0], dict):
+ seller_response = responses[0].get("response") or responses[0].get(
+ "responseText"
+ )
+ return {
+ "reviewId": raw.get("reviewId"),
+ "rating": raw.get("rating"),
+ "title": raw.get("reviewTitle"),
+ "text": raw.get("reviewText"),
+ "submissionTime": raw.get("reviewSubmissionTime"),
+ "author": raw.get("userNickname"),
+ "verifiedPurchase": verified,
+ "positiveFeedback": raw.get("positiveFeedback"),
+ "negativeFeedback": raw.get("negativeFeedback"),
+ "images": images,
+ "syndicated": bool(raw.get("syndicationSource")),
+ "sellerResponse": seller_response,
+ }
+
+
+def parse_reviews_page(next_data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Extract normalized reviews from one ``/reviews/product/{id}`` page."""
+ data = initial_data(next_data)
+ reviews = dig(data, "data", "reviews") if data else None
+ customer = reviews.get("customerReviews") if isinstance(reviews, dict) else None
+ if not isinstance(customer, list):
+ return []
+ return [normalize_review(r) for r in customer if isinstance(r, dict)]
From d446aa32ec083c0b481837f9eb0fbdc55b5880ea Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 08:19:22 +0200
Subject: [PATCH 097/217] feat(walmart): add scrape and review orchestration
---
.../proprietary/platforms/walmart/README.md | 52 ++++
.../proprietary/platforms/walmart/__init__.py | 27 ++
.../proprietary/platforms/walmart/scraper.py | 288 ++++++++++++++++++
3 files changed, 367 insertions(+)
create mode 100644 surfsense_backend/app/proprietary/platforms/walmart/README.md
create mode 100644 surfsense_backend/app/proprietary/platforms/walmart/__init__.py
create mode 100644 surfsense_backend/app/proprietary/platforms/walmart/scraper.py
diff --git a/surfsense_backend/app/proprietary/platforms/walmart/README.md b/surfsense_backend/app/proprietary/platforms/walmart/README.md
new file mode 100644
index 000000000..982ee5400
--- /dev/null
+++ b/surfsense_backend/app/proprietary/platforms/walmart/README.md
@@ -0,0 +1,52 @@
+# Walmart Scraper
+
+Two verbs read Walmart's public, anonymous pages: `walmart.scrape` (products +
+search/category/browse listings, per-product billing) and `walmart.reviews`
+(deep paginated reviews, per-review billing).
+
+## Scope
+
+The scraper reads public pages available to anonymous visitors — no login, no
+account cookies. Data is extracted from the Next.js `__NEXT_DATA__` JSON blob
+embedded in each page (with an `__APP_DATA__` fallback), not from the rendered
+DOM, because Walmart obfuscates CSS classes and A/B-tests layout constantly.
+
+`walmart.scrape` returns a free sample of on-page reviews (rating distribution,
+aspects, top reviews) under `reviewsSample`. `walmart.reviews` fetches the full
+review history from the public `/reviews/product/{usItemId}` page, which
+robots.txt permits (unlike `/search`).
+
+## Architecture
+
+- `schemas.py` defines the stable input, product, review, and error models.
+- `url_resolver.py` classifies product (`/ip/`) vs listing (`/search`, `/cp/`,
+ `/browse/`) URLs and extracts the numeric `usItemId`.
+- `next_data.py` extracts and navigates the hidden Next.js JSON state.
+- `fetch.py` owns proxy-aware HTTP access (US-pinned), block detection, and
+ retries.
+- `parsers.py` contains pure, defensive JSON parsers.
+- `scraper.py` coordinates discovery, enrichment, pagination, concurrency,
+ limits, and in-stream error items.
+
+## Anti-bot
+
+Walmart runs Akamai (edge/TLS) + PerimeterX/HUMAN (behavioral JS). Requests go
+through US residential proxies with TLS-impersonated headers; blocked responses
+(body markers, `412`/`429`/`503`, or the `200`-OK CAPTCHA body) rotate to a
+fresh proxy exit.
+
+Known ceilings and upgrade paths (see `fetch.py` / `scraper.py` `ponytail:`
+notes): reviews page at 10/page; search capped at Walmart's 25-page limit;
+session warming (seed `_px3`/`_pxhd` on a sticky exit) is the next lever if
+block rates on the SSR pages climb, and the `/orchestra/*` GraphQL API is
+deliberately avoided (rotating persisted-query hashes make it brittle).
+
+## Verification
+
+Offline fixtures cover the parsers and both flows:
+
+ uv run pytest tests/unit/platforms/walmart/
+
+A manual live check (requires network + residential proxy):
+
+ uv run python scripts/e2e_walmart_scraper.py
diff --git a/surfsense_backend/app/proprietary/platforms/walmart/__init__.py b/surfsense_backend/app/proprietary/platforms/walmart/__init__.py
new file mode 100644
index 000000000..83b7cf277
--- /dev/null
+++ b/surfsense_backend/app/proprietary/platforms/walmart/__init__.py
@@ -0,0 +1,27 @@
+"""Platform-native Walmart scraper (products, listings, reviews)."""
+
+from .schemas import (
+ ErrorItem,
+ ProductItem,
+ ReviewItem,
+ WalmartReviewsInput,
+ WalmartScrapeInput,
+)
+from .scraper import (
+ iter_products,
+ iter_reviews,
+ scrape_products,
+ scrape_reviews,
+)
+
+__all__ = [
+ "ErrorItem",
+ "ProductItem",
+ "ReviewItem",
+ "WalmartReviewsInput",
+ "WalmartScrapeInput",
+ "iter_products",
+ "iter_reviews",
+ "scrape_products",
+ "scrape_reviews",
+]
diff --git a/surfsense_backend/app/proprietary/platforms/walmart/scraper.py b/surfsense_backend/app/proprietary/platforms/walmart/scraper.py
new file mode 100644
index 000000000..e883efdbc
--- /dev/null
+++ b/surfsense_backend/app/proprietary/platforms/walmart/scraper.py
@@ -0,0 +1,288 @@
+"""Orchestrate public Walmart product discovery, enrichment, and reviews.
+
+Two streaming cores: :func:`iter_products` dispatches each start URL to a product
+or listing flow, and :func:`iter_reviews` paginates the public reviews page per
+item id. Network and parsing concerns stay isolated in their own modules so
+markup changes and retry policy can be tested independently.
+
+The failure model is in-stream error items (dicts with an ``error`` key), never
+exceptions — identical to the Amazon scraper.
+"""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+from typing import Any
+from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
+
+from .fetch import fetch_page, gather_bounded
+from .next_data import extract_next_data
+from .parsers import parse_listing_page, parse_product, parse_reviews_page
+from .schemas import (
+ ErrorItem,
+ ProductItem,
+ ReviewItem,
+ WalmartReviewsInput,
+ WalmartScrapeInput,
+)
+from .url_resolver import ResolvedUrl, extract_item_id, resolve_url
+
+__all__ = ["iter_products", "iter_reviews", "scrape_products", "scrape_reviews"]
+
+_DETAIL_CONCURRENCY = 6
+_SEARCH_PAGE_LIMIT = 25 # Walmart caps result pagination at 25 pages.
+_REVIEWS_PAGE_LIMIT = 500 # 10 reviews/page → 5000-review safety ceiling.
+_DEFAULT_ITEMS_PER_START_URL = 40
+
+# Friendly sort → Walmart's ``sort`` query value on the reviews page.
+_REVIEW_SORT = {
+ "most-recent": "submission-desc",
+ "most-helpful": "helpful",
+ "rating-high": "rating-desc",
+ "rating-low": "rating-asc",
+}
+
+
+def _error(
+ code: str, description: str, *, input_url: str | None, url: str | None = None
+) -> dict[str, Any]:
+ return ErrorItem(
+ error=code,
+ errorDescription=description,
+ input=input_url,
+ url=url or input_url,
+ ).model_dump()
+
+
+def _page_url(url: str, page: int) -> str:
+ parsed = urlparse(url)
+ query = dict(parse_qsl(parsed.query, keep_blank_values=True))
+ query["page"] = str(page)
+ return urlunparse(parsed._replace(query=urlencode(query)))
+
+
+def _product_url(item_id: str) -> str:
+ return f"https://www.walmart.com/ip/{item_id}"
+
+
+def _reviews_url(item_id: str, page: int, sort: str) -> str:
+ query = urlencode(
+ {"page": page, "sort": sort, "entryPoint": "viewAllReviewsBottom"}
+ )
+ return f"https://www.walmart.com/reviews/product/{item_id}?{query}"
+
+
+# --------------------------------------------------------------------------- #
+# Product / listing flows. #
+# --------------------------------------------------------------------------- #
+
+
+async def _product_flow(
+ resolved: ResolvedUrl, input_model: WalmartScrapeInput
+) -> AsyncIterator[dict[str, Any]]:
+ """Fetch and parse one product detail page."""
+ url = _product_url(resolved.item_id) if resolved.item_id else resolved.url
+ response = await fetch_page(url, country=input_model.country)
+ if response is None:
+ yield _error(
+ "product_not_found",
+ "The product page could not be loaded after retrying proxy exits.",
+ input_url=resolved.url,
+ )
+ return
+ if response.status in {404, 410}:
+ yield _error(
+ "product_not_found",
+ "The product page was not found.",
+ input_url=resolved.url,
+ )
+ return
+
+ next_data = extract_next_data(response.html)
+ fields = (
+ parse_product(
+ next_data,
+ url=response.url,
+ include_reviews_sample=input_model.includeReviewsSample,
+ )
+ if next_data
+ else {}
+ )
+ if not fields.get("name"):
+ yield _error(
+ "product_not_found",
+ "The response did not contain a recognizable product.",
+ input_url=resolved.url,
+ url=response.url,
+ )
+ return
+ fields["input"] = resolved.url
+ yield ProductItem(**fields).to_output()
+
+
+async def _listing_flow(
+ resolved: ResolvedUrl, input_model: WalmartScrapeInput
+) -> AsyncIterator[dict[str, Any]]:
+ """Page through a search/category/browse listing and optionally enrich."""
+ cap = (
+ input_model.maxItemsPerStartUrl
+ if input_model.maxItemsPerStartUrl is not None
+ else _DEFAULT_ITEMS_PER_START_URL
+ )
+ max_pages = min(input_model.maxSearchPagesPerStartUrl, _SEARCH_PAGE_LIMIT)
+ seen: set[str] = set()
+ emitted = 0
+ for page in range(1, max_pages + 1):
+ response = await fetch_page(
+ _page_url(resolved.url, page), country=input_model.country
+ )
+ next_data = extract_next_data(response.html) if response else None
+ cards = parse_listing_page(next_data) if next_data else []
+ cards = [c for c in cards if c["usItemId"] not in seen]
+ for card in cards:
+ seen.add(card["usItemId"])
+ if not cards:
+ if page == 1:
+ yield _error(
+ "no_results_found",
+ "The listing page did not contain any products.",
+ input_url=resolved.url,
+ )
+ return
+ cards = cards[: max(0, cap - emitted)]
+
+ if not input_model.includeDetails:
+ for card in cards:
+ card["input"] = resolved.url
+ yield ProductItem(**card).to_output()
+ emitted += 1
+ else:
+
+ async def load_card(card: dict[str, Any]) -> list[dict[str, Any]]:
+ product = resolve_url(card["url"]) if card.get("url") else None
+ if product is None or product.item_id is None:
+ card["input"] = resolved.url
+ return [ProductItem(**card).to_output()]
+ return [item async for item in _product_flow(product, input_model)]
+
+ for batch in await gather_bounded(
+ [lambda card=card: load_card(card) for card in cards],
+ concurrency=_DETAIL_CONCURRENCY,
+ ):
+ for item in batch:
+ if "error" not in item and emitted >= cap:
+ return
+ yield item
+ if "error" not in item:
+ emitted += 1
+ if emitted >= cap:
+ return
+
+
+_FLOWS = {"product": _product_flow, "listing": _listing_flow}
+
+
+async def iter_products(
+ input_model: WalmartScrapeInput,
+) -> AsyncIterator[dict[str, Any]]:
+ """Yield product items for every start URL.
+
+ Each URL is classified and dispatched to its per-kind flow. An unrecognized
+ URL yields an ``invalid_url`` error item.
+ """
+ for url in input_model.startUrls:
+ resolved = resolve_url(url)
+ if resolved is None:
+ yield _error(
+ "invalid_url",
+ "Start URL was malformed or not a recognized Walmart URL.",
+ input_url=url,
+ )
+ continue
+ async for item in _FLOWS[resolved.kind](resolved, input_model):
+ yield item
+
+
+async def scrape_products(
+ input_model: WalmartScrapeInput, *, limit: int | None = None
+) -> list[dict[str, Any]]:
+ """Collect :func:`iter_products` into a list, honoring an optional ``limit``."""
+ from app.capabilities.core.progress import emit_progress
+
+ results: list[dict[str, Any]] = []
+ async for item in iter_products(input_model):
+ results.append(item)
+ emit_progress("scraping", current=len(results), total=limit, unit="product")
+ if limit is not None and len(results) >= limit:
+ break
+ return results
+
+
+# --------------------------------------------------------------------------- #
+# Reviews flow (deep pagination). #
+# --------------------------------------------------------------------------- #
+
+
+async def _reviews_flow(
+ item_id: str, input_model: WalmartReviewsInput
+) -> AsyncIterator[dict[str, Any]]:
+ """Paginate the public reviews page until empty or ``maxReviews`` reached.
+
+ Walmart does not expose a reliable total up front, so the loop stops on the
+ first empty page rather than trusting a page count.
+ """
+ sort = _REVIEW_SORT[input_model.sort]
+ emitted = 0
+ for page in range(1, _REVIEWS_PAGE_LIMIT + 1):
+ response = await fetch_page(
+ _reviews_url(item_id, page, sort), country=input_model.country
+ )
+ next_data = extract_next_data(response.html) if response else None
+ reviews = parse_reviews_page(next_data) if next_data else []
+ if not reviews:
+ if page == 1 and emitted == 0:
+ yield _error(
+ "reviews_not_found",
+ "The product has no public reviews or could not be loaded.",
+ input_url=item_id,
+ )
+ return
+ for raw in reviews:
+ raw["usItemId"] = item_id
+ raw["input"] = item_id
+ yield ReviewItem(**raw).to_output()
+ emitted += 1
+ if emitted >= input_model.maxReviews:
+ return
+
+
+async def iter_reviews(
+ input_model: WalmartReviewsInput,
+) -> AsyncIterator[dict[str, Any]]:
+ """Yield review items for every requested item id (or resolvable URL)."""
+ for raw_id in input_model.itemIds:
+ item_id = extract_item_id(raw_id)
+ if item_id is None:
+ yield _error(
+ "invalid_url",
+ "Could not extract a Walmart item id from the input.",
+ input_url=raw_id,
+ )
+ continue
+ async for item in _reviews_flow(item_id, input_model):
+ yield item
+
+
+async def scrape_reviews(
+ input_model: WalmartReviewsInput, *, limit: int | None = None
+) -> list[dict[str, Any]]:
+ """Collect :func:`iter_reviews` into a list, honoring an optional ``limit``."""
+ from app.capabilities.core.progress import emit_progress
+
+ results: list[dict[str, Any]] = []
+ async for item in iter_reviews(input_model):
+ results.append(item)
+ emit_progress("scraping", current=len(results), total=limit, unit="review")
+ if limit is not None and len(results) >= limit:
+ break
+ return results
From 9b9091ae97cf9385510e7e43671547f54137ac5d Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 08:19:22 +0200
Subject: [PATCH 098/217] feat(walmart): add scrape and reviews capabilities
---
.../app/capabilities/walmart/__init__.py | 6 ++
.../capabilities/walmart/reviews/__init__.py | 3 +
.../walmart/reviews/definition.py | 24 +++++++
.../capabilities/walmart/reviews/executor.py | 40 +++++++++++
.../capabilities/walmart/reviews/schemas.py | 72 +++++++++++++++++++
.../capabilities/walmart/scrape/__init__.py | 3 +
.../capabilities/walmart/scrape/definition.py | 22 ++++++
.../capabilities/walmart/scrape/executor.py | 46 ++++++++++++
.../capabilities/walmart/scrape/schemas.py | 64 +++++++++++++++++
9 files changed, 280 insertions(+)
create mode 100644 surfsense_backend/app/capabilities/walmart/__init__.py
create mode 100644 surfsense_backend/app/capabilities/walmart/reviews/__init__.py
create mode 100644 surfsense_backend/app/capabilities/walmart/reviews/definition.py
create mode 100644 surfsense_backend/app/capabilities/walmart/reviews/executor.py
create mode 100644 surfsense_backend/app/capabilities/walmart/reviews/schemas.py
create mode 100644 surfsense_backend/app/capabilities/walmart/scrape/__init__.py
create mode 100644 surfsense_backend/app/capabilities/walmart/scrape/definition.py
create mode 100644 surfsense_backend/app/capabilities/walmart/scrape/executor.py
create mode 100644 surfsense_backend/app/capabilities/walmart/scrape/schemas.py
diff --git a/surfsense_backend/app/capabilities/walmart/__init__.py b/surfsense_backend/app/capabilities/walmart/__init__.py
new file mode 100644
index 000000000..1031f4230
--- /dev/null
+++ b/surfsense_backend/app/capabilities/walmart/__init__.py
@@ -0,0 +1,6 @@
+"""``walmart.*`` namespace: platform-native Walmart data verbs."""
+
+from __future__ import annotations
+
+from app.capabilities.walmart.reviews import definition as _reviews # noqa: F401
+from app.capabilities.walmart.scrape import definition as _scrape # noqa: F401
diff --git a/surfsense_backend/app/capabilities/walmart/reviews/__init__.py b/surfsense_backend/app/capabilities/walmart/reviews/__init__.py
new file mode 100644
index 000000000..473d60402
--- /dev/null
+++ b/surfsense_backend/app/capabilities/walmart/reviews/__init__.py
@@ -0,0 +1,3 @@
+"""Walmart deep review scraping capability."""
+
+from __future__ import annotations
diff --git a/surfsense_backend/app/capabilities/walmart/reviews/definition.py b/surfsense_backend/app/capabilities/walmart/reviews/definition.py
new file mode 100644
index 000000000..6984ee031
--- /dev/null
+++ b/surfsense_backend/app/capabilities/walmart/reviews/definition.py
@@ -0,0 +1,24 @@
+"""``walmart.reviews`` capability registration (billed per review; see config
+``WALMART_MICROS_PER_REVIEW``)."""
+
+from __future__ import annotations
+
+from app.capabilities.core import BillingUnit, Capability, register_capability
+from app.capabilities.walmart.reviews.executor import build_reviews_executor
+from app.capabilities.walmart.reviews.schemas import ReviewsInput, ReviewsOutput
+
+WALMART_REVIEWS = Capability(
+ name="walmart.reviews",
+ description=(
+ "Fetch deep paginated public Walmart product reviews with ratings, text, "
+ "authors, verified-purchase flags, images, and seller responses. Use "
+ "product urls or item ids."
+ ),
+ input_schema=ReviewsInput,
+ output_schema=ReviewsOutput,
+ executor=build_reviews_executor(),
+ billing_unit=BillingUnit.WALMART_REVIEW,
+ docs_url="/docs/connectors/native/walmart",
+)
+
+register_capability(WALMART_REVIEWS)
diff --git a/surfsense_backend/app/capabilities/walmart/reviews/executor.py b/surfsense_backend/app/capabilities/walmart/reviews/executor.py
new file mode 100644
index 000000000..35f88e11a
--- /dev/null
+++ b/surfsense_backend/app/capabilities/walmart/reviews/executor.py
@@ -0,0 +1,40 @@
+"""``walmart.reviews`` executor: verb input → scraper → review items."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+
+from app.capabilities.core import Executor
+from app.capabilities.core.progress import emit_progress
+from app.capabilities.walmart.reviews.schemas import ReviewsInput, ReviewsOutput
+from app.proprietary.platforms.walmart import WalmartReviewsInput, scrape_reviews
+
+ReviewsFn = Callable[..., Awaitable[list[dict]]]
+
+
+def build_reviews_executor(scrape_fn: ReviewsFn | None = None) -> Executor:
+ """Bind the executor to a reviews scraper fn (defaults to the proprietary actor)."""
+ scrape_fn = scrape_fn or scrape_reviews
+
+ async def execute(payload: ReviewsInput) -> ReviewsOutput:
+ input_model = WalmartReviewsInput(
+ itemIds=payload.sources(),
+ maxReviews=payload.max_reviews,
+ sort=payload.sort_by,
+ )
+ emit_progress(
+ "starting",
+ "Fetching Walmart reviews",
+ total=payload.estimated_units,
+ unit="review",
+ )
+ items = await scrape_fn(input_model, limit=payload.estimated_units)
+ emit_progress(
+ "done",
+ f"Scraped {sum('error' not in item for item in items)} review(s)",
+ current=len(items),
+ unit="review",
+ )
+ return ReviewsOutput(items=items)
+
+ return execute
diff --git a/surfsense_backend/app/capabilities/walmart/reviews/schemas.py b/surfsense_backend/app/capabilities/walmart/reviews/schemas.py
new file mode 100644
index 000000000..faf56ed5b
--- /dev/null
+++ b/surfsense_backend/app/capabilities/walmart/reviews/schemas.py
@@ -0,0 +1,72 @@
+"""``walmart.reviews`` I/O contracts.
+
+A lean surface over ``WalmartReviewsInput``; the scraper's ``ReviewItem`` is
+reused verbatim as the output element. Accepts product URLs or bare item ids —
+both resolve to a ``usItemId`` the reviews page is keyed on.
+"""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, Field, model_validator
+
+from app.proprietary.platforms.walmart import ReviewItem
+
+MAX_WALMART_REVIEW_SOURCES = 20
+
+
+class ReviewsInput(BaseModel):
+ urls: list[str] = Field(
+ default_factory=list,
+ max_length=MAX_WALMART_REVIEW_SOURCES,
+ description=(
+ "Walmart product URLs (/ip/...) or reviews URLs to fetch reviews for. "
+ "Provide these OR item_ids (at least one is required)."
+ ),
+ )
+ item_ids: list[str] = Field(
+ default_factory=list,
+ max_length=MAX_WALMART_REVIEW_SOURCES,
+ description="Walmart numeric item ids (usItemId) to fetch reviews for.",
+ )
+ max_reviews: int = Field(
+ default=200,
+ ge=1,
+ le=5000,
+ description="Max reviews to return per product (10 per page).",
+ )
+ sort_by: Literal["most-recent", "most-helpful", "rating-high", "rating-low"] = (
+ Field(default="most-recent", description="Review ordering.")
+ )
+
+ @model_validator(mode="after")
+ def _require_a_source(self) -> ReviewsInput:
+ if not (self.urls or self.item_ids):
+ raise ValueError("Provide at least one of 'urls' or 'item_ids'.")
+ return self
+
+ def sources(self) -> list[str]:
+ """URLs and item ids merged; the scraper resolves each to a usItemId."""
+ return [*self.urls, *self.item_ids]
+
+ @property
+ def estimated_units(self) -> int:
+ """Worst-case billable reviews: up to ``max_reviews`` per source."""
+ return (len(self.urls) + len(self.item_ids)) * self.max_reviews
+
+
+class ReviewsOutput(BaseModel):
+ items: list[ReviewItem] = Field(
+ default_factory=list,
+ description="One item per review, in the scraper's emission order.",
+ )
+
+ @property
+ def billable_units(self) -> int:
+ """One returned review = one billable unit; error items are not billed."""
+ return sum(
+ 1
+ for item in self.items
+ if not (item.model_extra and item.model_extra.get("error"))
+ )
diff --git a/surfsense_backend/app/capabilities/walmart/scrape/__init__.py b/surfsense_backend/app/capabilities/walmart/scrape/__init__.py
new file mode 100644
index 000000000..bb3e8f794
--- /dev/null
+++ b/surfsense_backend/app/capabilities/walmart/scrape/__init__.py
@@ -0,0 +1,3 @@
+"""Walmart product scraping capability."""
+
+from __future__ import annotations
diff --git a/surfsense_backend/app/capabilities/walmart/scrape/definition.py b/surfsense_backend/app/capabilities/walmart/scrape/definition.py
new file mode 100644
index 000000000..6165a8992
--- /dev/null
+++ b/surfsense_backend/app/capabilities/walmart/scrape/definition.py
@@ -0,0 +1,22 @@
+"""Registration for the ``walmart.scrape`` capability."""
+
+from __future__ import annotations
+
+from app.capabilities.core import BillingUnit, Capability, register_capability
+from app.capabilities.walmart.scrape.executor import build_scrape_executor
+from app.capabilities.walmart.scrape.schemas import ScrapeInput, ScrapeOutput
+
+WALMART_SCRAPE = Capability(
+ name="walmart.scrape",
+ description=(
+ "Scrape public Walmart product details, search/category listings, "
+ "prices, sellers, variants, availability, and a sample of on-page reviews."
+ ),
+ input_schema=ScrapeInput,
+ output_schema=ScrapeOutput,
+ executor=build_scrape_executor(),
+ billing_unit=BillingUnit.WALMART_PRODUCT,
+ docs_url="/docs/connectors/native/walmart",
+)
+
+register_capability(WALMART_SCRAPE)
diff --git a/surfsense_backend/app/capabilities/walmart/scrape/executor.py b/surfsense_backend/app/capabilities/walmart/scrape/executor.py
new file mode 100644
index 000000000..4c4b073ee
--- /dev/null
+++ b/surfsense_backend/app/capabilities/walmart/scrape/executor.py
@@ -0,0 +1,46 @@
+"""Executor for the ``walmart.scrape`` capability."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+
+from app.capabilities.core import Executor
+from app.capabilities.core.progress import emit_progress
+from app.capabilities.walmart.scrape.schemas import (
+ MAX_WALMART_RESULTS,
+ ScrapeInput,
+ ScrapeOutput,
+)
+from app.proprietary.platforms.walmart import WalmartScrapeInput, scrape_products
+
+ScrapeFn = Callable[..., Awaitable[list[dict]]]
+
+
+def build_scrape_executor(scrape_fn: ScrapeFn | None = None) -> Executor:
+ """Bind the capability input mapping to a replaceable scraper function."""
+ scrape_fn = scrape_fn or scrape_products
+
+ async def execute(payload: ScrapeInput) -> ScrapeOutput:
+ input_model = WalmartScrapeInput(
+ startUrls=payload.start_urls(),
+ maxItemsPerStartUrl=payload.max_items,
+ includeDetails=payload.include_details,
+ includeReviewsSample=payload.include_reviews_sample,
+ )
+ emit_progress(
+ "starting",
+ "Scraping Walmart products",
+ total=payload.estimated_units,
+ unit="product",
+ )
+ items = await scrape_fn(input_model, limit=MAX_WALMART_RESULTS)
+ emit_progress(
+ "done",
+ f"Scraped {sum('error' not in item for item in items)} product(s)",
+ current=len(items),
+ total=payload.estimated_units,
+ unit="product",
+ )
+ return ScrapeOutput(items=items)
+
+ return execute
diff --git a/surfsense_backend/app/capabilities/walmart/scrape/schemas.py b/surfsense_backend/app/capabilities/walmart/scrape/schemas.py
new file mode 100644
index 000000000..2d42dc371
--- /dev/null
+++ b/surfsense_backend/app/capabilities/walmart/scrape/schemas.py
@@ -0,0 +1,64 @@
+"""Input and output contracts for ``walmart.scrape``."""
+
+from __future__ import annotations
+
+from urllib.parse import quote_plus
+
+from pydantic import BaseModel, Field, model_validator
+
+from app.proprietary.platforms.walmart import ProductItem
+
+MAX_WALMART_SOURCES = 20
+MAX_WALMART_RESULTS = 1000
+
+
+class ScrapeInput(BaseModel):
+ """Agent-facing controls for public Walmart product discovery and enrichment."""
+
+ urls: list[str] = Field(default_factory=list, max_length=MAX_WALMART_SOURCES)
+ search_terms: list[str] = Field(
+ default_factory=list, max_length=MAX_WALMART_SOURCES
+ )
+ max_items: int = Field(default=10, ge=1, le=100)
+ include_details: bool = True
+ include_reviews_sample: bool = True
+
+ @model_validator(mode="after")
+ def _require_source(self) -> ScrapeInput:
+ if not (self.urls or self.search_terms):
+ raise ValueError("Provide at least one URL or search term.")
+ if len(self.urls) + len(self.search_terms) > MAX_WALMART_SOURCES:
+ raise ValueError(
+ f"Provide no more than {MAX_WALMART_SOURCES} combined sources."
+ )
+ return self
+
+ def start_urls(self) -> list[str]:
+ """Direct URLs plus a search URL synthesized per search term."""
+ searches = [
+ f"https://www.walmart.com/search?q={quote_plus(term)}"
+ for term in self.search_terms
+ ]
+ return [*self.urls, *searches]
+
+ @property
+ def estimated_units(self) -> int:
+ """Worst-case returned products within the hard per-run ceiling."""
+ search_products = len(self.search_terms) * self.max_items
+ direct_products = len(self.urls) * self.max_items
+ return min(search_products + direct_products, MAX_WALMART_RESULTS)
+
+
+class ScrapeOutput(BaseModel):
+ """Products and structured per-input errors in emission order."""
+
+ items: list[ProductItem] = Field(default_factory=list)
+
+ @property
+ def billable_units(self) -> int:
+ """Count successful products; error items are never billed."""
+ return sum(
+ 1
+ for item in self.items
+ if not (item.model_extra and item.model_extra.get("error"))
+ )
From a3f8a7e7413e1405ae793a890c7f07ec27056b67 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 08:19:22 +0200
Subject: [PATCH 099/217] feat(walmart): register routes and MCP tools
---
surfsense_backend/app/routes/__init__.py | 1 +
.../mcp_server/features/scrapers/__init__.py | 7 +-
.../features/scrapers/platforms/walmart.py | 140 ++++++++++++++++++
3 files changed, 146 insertions(+), 2 deletions(-)
create mode 100644 surfsense_mcp/mcp_server/features/scrapers/platforms/walmart.py
diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py
index 509233e15..e76d3ea1d 100644
--- a/surfsense_backend/app/routes/__init__.py
+++ b/surfsense_backend/app/routes/__init__.py
@@ -7,6 +7,7 @@ import app.capabilities.google_search
import app.capabilities.instagram
import app.capabilities.reddit
import app.capabilities.tiktok
+import app.capabilities.walmart
import app.capabilities.web
import app.capabilities.youtube # noqa: F401
from app.automations.api import router as automations_router
diff --git a/surfsense_mcp/mcp_server/features/scrapers/__init__.py b/surfsense_mcp/mcp_server/features/scrapers/__init__.py
index e6f1055cd..524250060 100644
--- a/surfsense_mcp/mcp_server/features/scrapers/__init__.py
+++ b/surfsense_mcp/mcp_server/features/scrapers/__init__.py
@@ -1,7 +1,8 @@
"""Scraper tools: one MCP surface per SurfSense platform capability.
-Web crawl, Google Search, Reddit, YouTube, and Google Maps each get a tool that
-maps a natural-language request to the workspace's scraper. Two run-history tools
+Web crawl, Google Search, Reddit, YouTube, Google Maps, Amazon, and Walmart each
+get a tool that maps a natural-language request to the workspace's scraper. Two
+run-history tools
list and fetch past runs, so a large result truncated inline can be retrieved in
full later. Each platform lives in its own module under platforms/.
"""
@@ -20,6 +21,7 @@ from .platforms import (
instagram,
reddit,
tiktok,
+ walmart,
web,
youtube,
)
@@ -33,6 +35,7 @@ _REGISTRARS = (
tiktok,
google_maps,
amazon,
+ walmart,
run_history,
)
diff --git a/surfsense_mcp/mcp_server/features/scrapers/platforms/walmart.py b/surfsense_mcp/mcp_server/features/scrapers/platforms/walmart.py
new file mode 100644
index 000000000..0c11af653
--- /dev/null
+++ b/surfsense_mcp/mcp_server/features/scrapers/platforms/walmart.py
@@ -0,0 +1,140 @@
+"""Walmart scraper tools: products/listings and deep reviews."""
+
+from __future__ import annotations
+
+from typing import Annotated, Literal
+
+from mcp.server.fastmcp import FastMCP
+from pydantic import Field
+
+from ....core.client import SurfSenseClient
+from ....core.rendering import ResponseFormatParam
+from ....core.workspace_context import WorkspaceContext, WorkspaceParam
+from ..annotations import SCRAPE
+from ..capability import run_scraper
+
+ReviewSort = Literal["most-recent", "most-helpful", "rating-high", "rating-low"]
+
+
+def register(mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext) -> None:
+ """Register the Walmart product and review tools."""
+
+ @mcp.tool(
+ name="surfsense_walmart_scrape",
+ title="Scrape Walmart products",
+ annotations=SCRAPE,
+ structured_output=False,
+ )
+ async def walmart_scrape(
+ urls: Annotated[
+ list[str] | None,
+ Field(
+ description="Walmart product (/ip/), search (/search), category "
+ "(/cp/), or browse (/browse/) URLs. Provide urls OR search_terms."
+ ),
+ ] = None,
+ search_terms: Annotated[
+ list[str] | None,
+ Field(
+ description="Search phrases run on walmart.com, e.g. ['air fryer']. "
+ "Provide search_terms OR urls."
+ ),
+ ] = None,
+ max_items: Annotated[
+ int,
+ Field(ge=1, le=100, description="Max products per search term or listing URL."),
+ ] = 10,
+ include_details: Annotated[
+ bool,
+ Field(
+ description="Fetch full product detail pages. False returns faster "
+ "card-only results from listings."
+ ),
+ ] = True,
+ include_reviews_sample: Annotated[
+ bool,
+ Field(
+ description="Include the free on-page review sample (rating "
+ "distribution, aspects, top reviews) on detail pages."
+ ),
+ ] = True,
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Scrape public Walmart product data by URL or search term.
+
+ Use this for product research: title, price, list price, rating and
+ review count, availability, seller (Walmart 1P vs marketplace), images,
+ description, variants, and a sample of on-page reviews. Only public,
+ anonymous data — no login. For a product's full review history use
+ surfsense_walmart_reviews instead.
+ Example: search_terms=['air fryer'], max_items=5.
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="walmart",
+ verb="scrape",
+ payload={
+ "urls": urls,
+ "search_terms": search_terms,
+ "max_items": max_items,
+ "include_details": include_details,
+ "include_reviews_sample": include_reviews_sample,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
+
+ @mcp.tool(
+ name="surfsense_walmart_reviews",
+ title="Fetch Walmart reviews",
+ annotations=SCRAPE,
+ structured_output=False,
+ )
+ async def walmart_reviews(
+ urls: Annotated[
+ list[str] | None,
+ Field(
+ description="Walmart product URLs (/ip/...). Provide urls OR item_ids."
+ ),
+ ] = None,
+ item_ids: Annotated[
+ list[str] | None,
+ Field(
+ description="Walmart numeric item ids (usItemId) from "
+ "surfsense_walmart_scrape."
+ ),
+ ] = None,
+ max_reviews: Annotated[
+ int,
+ Field(ge=1, le=5000, description="Max reviews per product (10 per page)."),
+ ] = 200,
+ sort_by: Annotated[
+ ReviewSort, Field(description="Review ordering.")
+ ] = "most-recent",
+ workspace: WorkspaceParam = None,
+ response_format: ResponseFormatParam = "markdown",
+ ) -> str:
+ """Fetch deep paginated customer reviews for Walmart products.
+
+ Use this to read the full review history on specific products; get urls
+ or item_ids from surfsense_walmart_scrape first if you only have a name.
+ Returns rating, title, text, author, verified-purchase flag, images, and
+ seller response per review.
+ Example: item_ids=['212092810'], sort_by='most-helpful', max_reviews=100.
+ """
+ return await run_scraper(
+ client,
+ context,
+ platform="walmart",
+ verb="reviews",
+ payload={
+ "urls": urls,
+ "item_ids": item_ids,
+ "max_reviews": max_reviews,
+ "sort_by": sort_by,
+ },
+ workspace=workspace,
+ response_format=response_format,
+ )
From 7b5e09c528c2aebbe1ba861624ca48bd00c8e648 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 08:19:22 +0200
Subject: [PATCH 100/217] test(walmart): add parser and flow tests with e2e
harness
---
.../scripts/e2e_walmart_scraper.py | 80 ++++++++++
.../tests/unit/platforms/walmart/__init__.py | 0
.../platforms/walmart/fixtures/blocked.html | 4 +
.../platforms/walmart/fixtures/listing.html | 3 +
.../platforms/walmart/fixtures/product.html | 3 +
.../platforms/walmart/fixtures/reviews.html | 3 +
.../unit/platforms/walmart/test_flows.py | 138 ++++++++++++++++++
.../unit/platforms/walmart/test_parsers.py | 90 ++++++++++++
8 files changed, 321 insertions(+)
create mode 100644 surfsense_backend/scripts/e2e_walmart_scraper.py
create mode 100644 surfsense_backend/tests/unit/platforms/walmart/__init__.py
create mode 100644 surfsense_backend/tests/unit/platforms/walmart/fixtures/blocked.html
create mode 100644 surfsense_backend/tests/unit/platforms/walmart/fixtures/listing.html
create mode 100644 surfsense_backend/tests/unit/platforms/walmart/fixtures/product.html
create mode 100644 surfsense_backend/tests/unit/platforms/walmart/fixtures/reviews.html
create mode 100644 surfsense_backend/tests/unit/platforms/walmart/test_flows.py
create mode 100644 surfsense_backend/tests/unit/platforms/walmart/test_parsers.py
diff --git a/surfsense_backend/scripts/e2e_walmart_scraper.py b/surfsense_backend/scripts/e2e_walmart_scraper.py
new file mode 100644
index 000000000..28ed3a344
--- /dev/null
+++ b/surfsense_backend/scripts/e2e_walmart_scraper.py
@@ -0,0 +1,80 @@
+"""Manual end-to-end check for the public Walmart scraper.
+
+Run from the backend directory:
+
+ uv run python scripts/e2e_walmart_scraper.py
+
+The script requires live network access and the configured residential proxy
+(US exit). It is intentionally excluded from pytest.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import sys
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+_BACKEND_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(_BACKEND_ROOT))
+for _candidate in (_BACKEND_ROOT / ".env", _BACKEND_ROOT.parent / ".env"):
+ if _candidate.exists():
+ load_dotenv(_candidate)
+ break
+
+from app.proprietary.platforms.walmart import ( # noqa: E402
+ WalmartReviewsInput,
+ WalmartScrapeInput,
+ scrape_products,
+ scrape_reviews,
+)
+
+_PRODUCT_URL = "https://www.walmart.com/ip/212092810"
+_SEARCH_URL = "https://www.walmart.com/search?q=air+fryer"
+
+
+def _check(label: str, passed: bool) -> bool:
+ print(f"[{'PASS' if passed else 'FAIL'}] {label}")
+ return passed
+
+
+async def main() -> int:
+ ok = True
+
+ product_items = await scrape_products(
+ WalmartScrapeInput(startUrls=[_PRODUCT_URL]), limit=1
+ )
+ product = product_items[0] if product_items else {}
+ print(json.dumps(product, indent=2, ensure_ascii=False)[:2500])
+ ok &= _check(
+ "product detail has id and name",
+ bool(product.get("usItemId") and product.get("name")),
+ )
+
+ search_items = await scrape_products(
+ WalmartScrapeInput(
+ startUrls=[_SEARCH_URL], maxItemsPerStartUrl=3, includeDetails=False
+ ),
+ limit=3,
+ )
+ ok &= _check(
+ "search returns product cards",
+ bool(search_items) and any("name" in item for item in search_items),
+ )
+
+ item_id = product.get("usItemId") or "212092810"
+ reviews = await scrape_reviews(
+ WalmartReviewsInput(itemIds=[item_id], maxReviews=15), limit=15
+ )
+ ok &= _check(
+ "reviews returned with rating and text",
+ bool(reviews) and any(r.get("rating") and r.get("text") for r in reviews),
+ )
+
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(asyncio.run(main()))
diff --git a/surfsense_backend/tests/unit/platforms/walmart/__init__.py b/surfsense_backend/tests/unit/platforms/walmart/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/platforms/walmart/fixtures/blocked.html b/surfsense_backend/tests/unit/platforms/walmart/fixtures/blocked.html
new file mode 100644
index 000000000..a9e9d606e
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/walmart/fixtures/blocked.html
@@ -0,0 +1,4 @@
+Robot or human?
+Activate and hold the button to confirm that you're human.
+Robot or human?
+
diff --git a/surfsense_backend/tests/unit/platforms/walmart/fixtures/listing.html b/surfsense_backend/tests/unit/platforms/walmart/fixtures/listing.html
new file mode 100644
index 000000000..0ebb7e682
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/walmart/fixtures/listing.html
@@ -0,0 +1,3 @@
+laptop - Walmart.com
+
+
diff --git a/surfsense_backend/tests/unit/platforms/walmart/fixtures/product.html b/surfsense_backend/tests/unit/platforms/walmart/fixtures/product.html
new file mode 100644
index 000000000..3db17b152
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/walmart/fixtures/product.html
@@ -0,0 +1,3 @@
+Midea AC
+
+
diff --git a/surfsense_backend/tests/unit/platforms/walmart/fixtures/reviews.html b/surfsense_backend/tests/unit/platforms/walmart/fixtures/reviews.html
new file mode 100644
index 000000000..b0d27e5b6
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/walmart/fixtures/reviews.html
@@ -0,0 +1,3 @@
+Reviews
+
+
diff --git a/surfsense_backend/tests/unit/platforms/walmart/test_flows.py b/surfsense_backend/tests/unit/platforms/walmart/test_flows.py
new file mode 100644
index 000000000..045aa99d5
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/walmart/test_flows.py
@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from app.proprietary.platforms.walmart import (
+ WalmartReviewsInput,
+ WalmartScrapeInput,
+ scrape_products,
+ scrape_reviews,
+ scraper,
+)
+from app.proprietary.platforms.walmart.fetch import FetchResult
+
+_FIXTURES = Path(__file__).parent / "fixtures"
+
+
+def _fixture(name: str) -> str:
+ return (_FIXTURES / name).read_text(encoding="utf-8")
+
+
+def _response(url: str, html: str, status: int = 200) -> FetchResult:
+ return FetchResult(status=status, html=html, url=url, cookies={})
+
+
+async def test_product_flow_emits_parsed_item(monkeypatch):
+ async def fetch_page(url: str, **_kwargs):
+ return _response(url, _fixture("product.html"))
+
+ monkeypatch.setattr(scraper, "fetch_page", fetch_page)
+ items = await scrape_products(
+ WalmartScrapeInput(startUrls=["https://www.walmart.com/ip/212092810"])
+ )
+
+ assert len(items) == 1
+ assert items[0]["usItemId"] == "212092810"
+ assert items[0]["name"].startswith("Midea")
+
+
+async def test_product_flow_maps_not_found_to_error_item(monkeypatch):
+ async def fetch_page(url: str, **_kwargs):
+ return _response(url, "", status=404)
+
+ monkeypatch.setattr(scraper, "fetch_page", fetch_page)
+ items = await scrape_products(
+ WalmartScrapeInput(startUrls=["https://www.walmart.com/ip/212092810"])
+ )
+
+ assert items[0]["error"] == "product_not_found"
+
+
+async def test_listing_flow_card_only_honors_cap(monkeypatch):
+ calls: list[str] = []
+
+ async def fetch_page(url: str, **_kwargs):
+ calls.append(url)
+ html = _fixture("listing.html") if "page=1" in url else ""
+ return _response(url, html)
+
+ monkeypatch.setattr(scraper, "fetch_page", fetch_page)
+ items = await scrape_products(
+ WalmartScrapeInput(
+ startUrls=["https://www.walmart.com/search?q=laptop"],
+ maxItemsPerStartUrl=1,
+ includeDetails=False,
+ )
+ )
+
+ assert len(items) == 1
+ assert items[0]["usItemId"] == "791595618"
+ assert len(calls) == 1
+
+
+async def test_listing_flow_enriches_with_detail_pages(monkeypatch):
+ async def fetch_page(url: str, **_kwargs):
+ if "/ip/" in url:
+ return _response(url, _fixture("product.html"))
+ html = _fixture("listing.html") if "page=1" in url else ""
+ return _response(url, html)
+
+ monkeypatch.setattr(scraper, "fetch_page", fetch_page)
+ items = await scrape_products(
+ WalmartScrapeInput(
+ startUrls=["https://www.walmart.com/search?q=laptop"],
+ maxItemsPerStartUrl=2,
+ includeDetails=True,
+ )
+ )
+
+ # Both cards enrich to the same product fixture (detail fetch wins).
+ assert all(item["longDescription"] for item in items)
+
+
+async def test_invalid_url_yields_error_item(monkeypatch):
+ items = await scrape_products(
+ WalmartScrapeInput(startUrls=["https://example.com/not-walmart"])
+ )
+ assert items[0]["error"] == "invalid_url"
+
+
+async def test_reviews_flow_paginates_until_empty(monkeypatch):
+ calls: list[str] = []
+
+ async def fetch_page(url: str, **_kwargs):
+ calls.append(url)
+ html = _fixture("reviews.html") if "page=1" in url else ""
+ return _response(url, html)
+
+ monkeypatch.setattr(scraper, "fetch_page", fetch_page)
+ items = await scrape_reviews(
+ WalmartReviewsInput(itemIds=["212092810"], maxReviews=100)
+ )
+
+ assert len(items) == 2
+ assert items[0]["reviewId"] == "296013686"
+ # page=1 returned records, page=2 empty → stop. Two fetches total.
+ assert len(calls) == 2
+
+
+async def test_reviews_flow_honors_max_reviews(monkeypatch):
+ async def fetch_page(url: str, **_kwargs):
+ return _response(url, _fixture("reviews.html"))
+
+ monkeypatch.setattr(scraper, "fetch_page", fetch_page)
+ items = await scrape_reviews(
+ WalmartReviewsInput(itemIds=["212092810"], maxReviews=1)
+ )
+
+ assert len(items) == 1
+
+
+async def test_reviews_flow_maps_empty_to_error_item(monkeypatch):
+ async def fetch_page(url: str, **_kwargs):
+ return _response(url, "")
+
+ monkeypatch.setattr(scraper, "fetch_page", fetch_page)
+ items = await scrape_reviews(WalmartReviewsInput(itemIds=["212092810"]))
+
+ assert items[0]["error"] == "reviews_not_found"
diff --git a/surfsense_backend/tests/unit/platforms/walmart/test_parsers.py b/surfsense_backend/tests/unit/platforms/walmart/test_parsers.py
new file mode 100644
index 000000000..da66064ab
--- /dev/null
+++ b/surfsense_backend/tests/unit/platforms/walmart/test_parsers.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from app.proprietary.platforms.walmart.fetch import is_blocked
+from app.proprietary.platforms.walmart.next_data import extract_next_data
+from app.proprietary.platforms.walmart.parsers import (
+ parse_listing_page,
+ parse_product,
+ parse_reviews_page,
+)
+from app.proprietary.platforms.walmart.url_resolver import extract_item_id, resolve_url
+
+_FIXTURES = Path(__file__).parent / "fixtures"
+
+
+def _fixture(name: str) -> str:
+ return (_FIXTURES / name).read_text(encoding="utf-8")
+
+
+def test_product_parser_extracts_core_fields_and_review_sample():
+ data = extract_next_data(_fixture("product.html"))
+ item = parse_product(data, url="https://www.walmart.com/ip/212092810")
+
+ assert item["usItemId"] == "212092810"
+ assert item["name"].startswith("Midea")
+ assert item["price"] == {"value": 149.0, "currency": "USD"}
+ assert item["listPrice"] == {"value": 199.0, "currency": "USD"}
+ assert item["stars"] == 4.5
+ assert item["reviewsCount"] == 6287
+ assert item["inStock"] is True
+ assert item["seller"] == {"id": "F55CT", "name": "Walmart.com", "type": "WALMART"}
+ assert item["longDescription"] == "A powerful window unit.
"
+ assert item["images"] == [
+ "https://i5.walmartimages.com/1.jpg",
+ "https://i5.walmartimages.com/2.jpg",
+ ]
+ assert item["reviewsSample"]["totalReviewCount"] == 6287
+ assert item["reviewsSample"]["topReviews"][0]["verifiedPurchase"] is True
+
+
+def test_listing_parser_normalizes_cards_and_marketplace_seller():
+ data = extract_next_data(_fixture("listing.html"))
+ cards = parse_listing_page(data)
+
+ assert [c["usItemId"] for c in cards] == ["791595618", "999001"]
+ first = cards[0]
+ assert first["url"] == "https://www.walmart.com/ip/Acer-Chromebook/791595618"
+ assert first["price"] == {"value": 79.99, "currency": "USD"}
+ assert first["seller"]["type"] == "MARKETPLACE"
+ assert first["inStock"] is True
+ # Fallback price + out-of-stock derivation for the second card.
+ assert cards[1]["price"] == {"value": 499.0}
+ assert cards[1]["inStock"] is False
+
+
+def test_reviews_parser_extracts_all_records():
+ data = extract_next_data(_fixture("reviews.html"))
+ reviews = parse_reviews_page(data)
+
+ assert len(reviews) == 2
+ assert reviews[0]["reviewId"] == "296013686"
+ assert reviews[0]["author"] == "JohnPaul"
+ assert reviews[0]["verifiedPurchase"] is True
+ assert reviews[0]["images"] == ["https://i5.walmartimages.com/r.jpg"]
+ assert reviews[0]["sellerResponse"] == "Thanks for the review!"
+ assert reviews[1]["verifiedPurchase"] is False
+
+
+def test_block_detection_handles_status_and_body():
+ assert is_blocked(_fixture("blocked.html"), 200)
+ assert is_blocked("", 412)
+ assert is_blocked("", 429)
+ assert not is_blocked(_fixture("product.html"), 200)
+
+
+def test_missing_next_data_yields_none():
+ assert extract_next_data("no script here") is None
+ assert parse_product(extract_next_data("") or {}, url="x") == {}
+
+
+def test_url_resolver_classifies_and_extracts_ids():
+ assert resolve_url("https://www.walmart.com/ip/Foo/123456789").kind == "product"
+ assert (
+ extract_item_id("https://www.walmart.com/reviews/product/212092810")
+ == "212092810"
+ )
+ assert resolve_url("https://www.walmart.com/search?q=tv").kind == "listing"
+ assert resolve_url("https://www.walmart.com/cp/tvs/3944").kind == "listing"
+ assert resolve_url("https://example.com/ip/1") is None
From f6a8a2a566708ca22b74e30bdd832edeb9898a67 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 08:45:27 +0200
Subject: [PATCH 101/217] feat(walmart): add chat subagent with scrape and
reviews tools
---
.../subagents/builtins/walmart/__init__.py | 1 +
.../subagents/builtins/walmart/agent.py | 43 ++++++++++++
.../subagents/builtins/walmart/description.md | 2 +
.../builtins/walmart/system_prompt.md | 68 +++++++++++++++++++
.../builtins/walmart/tools/__init__.py | 0
.../subagents/builtins/walmart/tools/index.py | 28 ++++++++
6 files changed, 142 insertions(+)
create mode 100644 surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/__init__.py
create mode 100644 surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/agent.py
create mode 100644 surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/description.md
create mode 100644 surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/system_prompt.md
create mode 100644 surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/tools/__init__.py
create mode 100644 surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/tools/index.py
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/__init__.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/__init__.py
new file mode 100644
index 000000000..8126071cf
--- /dev/null
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/__init__.py
@@ -0,0 +1 @@
+"""``walmart`` builtin subagent: structured public Walmart product data and reviews."""
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/agent.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/agent.py
new file mode 100644
index 000000000..794b050a2
--- /dev/null
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/agent.py
@@ -0,0 +1,43 @@
+"""``walmart`` route: ``SurfSenseSubagentSpec`` builder for deepagents."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.language_models import BaseChatModel
+from langchain_core.tools import BaseTool
+
+from app.agents.chat.multi_agent_chat.subagents.shared.md_file_reader import (
+ read_md_file,
+)
+from app.agents.chat.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
+from app.agents.chat.multi_agent_chat.subagents.shared.subagent_builder import (
+ pack_subagent,
+)
+
+from .tools.index import NAME, RULESET, load_tools
+
+
+def build_subagent(
+ *,
+ dependencies: dict[str, Any],
+ model: BaseChatModel | None = None,
+ middleware_stack: dict[str, Any] | None = None,
+ mcp_tools: list[BaseTool] | None = None,
+) -> SurfSenseSubagentSpec:
+ tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
+ description = (
+ read_md_file(__package__, "description").strip()
+ or "Scrapes public Walmart product data and reviews for a URL or search term."
+ )
+ system_prompt = read_md_file(__package__, "system_prompt").strip()
+ return pack_subagent(
+ name=NAME,
+ description=description,
+ system_prompt=system_prompt,
+ tools=tools,
+ ruleset=RULESET,
+ dependencies=dependencies,
+ model=model,
+ middleware_stack=middleware_stack,
+ )
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/description.md b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/description.md
new file mode 100644
index 000000000..88c6b7c28
--- /dev/null
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/description.md
@@ -0,0 +1,2 @@
+Walmart product specialist: scrapes public Walmart listings and returns structured product data — title, item id (usItemId), brand, price and list price, star rating and review count, availability, images, features, seller, and product variants — plus deep paginated customer reviews (rating, text, author, verified-purchase flag, images, and seller responses). Works from a search term (e.g. "air fryer") or from Walmart product (/ip/...), search, category, or browse URLs, and can pull many reviews per product by item id or URL. Only public, anonymous US Walmart data — no login or seller account.
+Use it for product research, price tracking, catalog enrichment by item id, and review mining. Triggers include "find X on Walmart", "Walmart price of X", "reviews for this Walmart product", "compare these Walmart products", and "look up this Walmart URL/item id". Not for general web search (use the Google Search specialist), reading an arbitrary non-Walmart page (use the web crawling specialist), or other marketplaces.
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/system_prompt.md b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/system_prompt.md
new file mode 100644
index 000000000..bf30fe2bb
--- /dev/null
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/system_prompt.md
@@ -0,0 +1,68 @@
+You are the SurfSense Walmart sub-agent.
+You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis.
+
+
+Answer the delegated question from public Walmart product data and reviews gathered with your verbs, comparing against earlier results already in this conversation when the task calls for it.
+
+
+
+- `walmart_scrape` — product details and search/category listings
+- `walmart_reviews` — deep paginated reviews for a product
+- `read_run` / `search_run` (free readers for stored scrape output)
+
+
+
+- Discovering products: call `walmart_scrape` with `search_terms` (e.g. ["air fryer"]).
+- Specific products: pass Walmart product URLs (/ip/...) or search/category/browse URLs in `urls`.
+- Faster listings: set `include_details=false` to return card-only results without opening each product page.
+- Sampled reviews: `walmart_scrape` returns a small on-page review sample by default (`include_reviews_sample=true`); disable it when reviews are irrelevant.
+- Deep review mining: use `walmart_reviews` with product `urls` or numeric `item_ids` (usItemId); raise `max_reviews` and set `sort_by` (most-recent, most-helpful, rating-high, rating-low) as the task needs. Reviews are billed per review, so keep `max_reviews` to what the task actually requires.
+- Batch multiple URLs or search terms into one call rather than many single-source calls.
+
+- Comparison requests: pull the current products, compare against prior values already in this conversation's earlier tool results, and report concrete deltas (price up/down, rating change, stock changes).
+
+
+
+- Use only tools in ``.
+- Report only results present in the tool output. Never invent titles, item ids, prices, ratings, or reviews.
+- `walmart_scrape`: provide at least one of `urls` or `search_terms`.
+- `walmart_reviews`: provide at least one of `urls` or `item_ids`.
+
+
+
+- Do not perform general web search — that is the Google Search specialist's job.
+- Do not read or extract an arbitrary non-Walmart page — return the URL for the web crawling specialist.
+- Do not generate deliverables or perform connector mutations; return findings for the supervisor to act on.
+- Only public, anonymous Walmart data — never anything behind a login or seller account.
+
+
+
+- Report uncertainty explicitly when evidence is incomplete or conflicting.
+- Never present unverified claims as facts.
+
+
+
+- Underspecified request — no usable search term, URL, or item id — return `status=blocked` with the missing fields.
+- Tool failure: return `status=error` with a concise recovery `next_step`.
+- No useful evidence: return `status=blocked` with a narrower query or the scope you still need.
+
+
+
+Return **only** one JSON object (no markdown/prose):
+{
+ "status": "success" | "partial" | "blocked" | "error",
+ "action_summary": string,
+ "evidence": {
+ "findings": string[],
+ "sources": string[],
+ "confidence": "high" | "medium" | "low"
+ },
+ "next_step": string | null,
+ "missing_fields": string[] | null,
+ "assumptions": string[] | null
+}
+
+Route-specific rules:
+- `evidence.findings`: max 10 entries, each a single sentence stating one distinct product, review theme, or delta. Do not paste raw payloads.
+- `evidence.sources`: max 10 URLs, one per finding when applicable. List each URL once.
+
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/tools/__init__.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/tools/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/tools/index.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/tools/index.py
new file mode 100644
index 000000000..ac3e5ed81
--- /dev/null
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/walmart/tools/index.py
@@ -0,0 +1,28 @@
+"""``walmart`` sub-agent tools: the Walmart product scrape and reviews verbs."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.tools import BaseTool
+
+from app.agents.chat.multi_agent_chat.shared.permissions import Ruleset
+from app.capabilities.core.access.agent import build_capability_tools
+from app.capabilities.walmart.reviews.definition import WALMART_REVIEWS
+from app.capabilities.walmart.scrape.definition import WALMART_SCRAPE
+
+NAME = "walmart"
+
+RULESET = Ruleset(origin=NAME, rules=[])
+
+_CI_VERBS = [WALMART_SCRAPE, WALMART_REVIEWS]
+
+
+def load_tools(
+ *, dependencies: dict[str, Any] | None = None, **kwargs: Any
+) -> list[BaseTool]:
+ d = {**(dependencies or {}), **kwargs}
+ return build_capability_tools(
+ workspace_id=d.get("workspace_id"),
+ capabilities=_CI_VERBS,
+ )
From cabc5b5c86b0b602bae2a52ef99a1cbfd93ce624 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 08:45:27 +0200
Subject: [PATCH 102/217] feat(walmart): register subagent in roster and
connector map
---
.../app/agents/chat/multi_agent_chat/constants.py | 1 +
.../app/agents/chat/multi_agent_chat/subagents/registry.py | 4 ++++
.../unit/agents/multi_agent_chat/test_subagent_composition.py | 1 +
3 files changed, 6 insertions(+)
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/constants.py b/surfsense_backend/app/agents/chat/multi_agent_chat/constants.py
index 333d4d274..2c44dbefe 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/constants.py
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/constants.py
@@ -39,6 +39,7 @@ SUBAGENT_TO_REQUIRED_CONNECTOR_MAP: dict[str, frozenset[str]] = {
"reddit": frozenset(),
"instagram": frozenset(),
"tiktok": frozenset(),
+ "walmart": frozenset(),
"mcp_discovery": frozenset(
{
"SLACK_CONNECTOR",
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/registry.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/registry.py
index 7b8b8a6f1..48de3e245 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/registry.py
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/registry.py
@@ -42,6 +42,9 @@ from app.agents.chat.multi_agent_chat.subagents.builtins.reddit.agent import (
from app.agents.chat.multi_agent_chat.subagents.builtins.tiktok.agent import (
build_subagent as build_tiktok_subagent,
)
+from app.agents.chat.multi_agent_chat.subagents.builtins.walmart.agent import (
+ build_subagent as build_walmart_subagent,
+)
from app.agents.chat.multi_agent_chat.subagents.builtins.web_crawler.agent import (
build_subagent as build_web_crawler_subagent,
)
@@ -96,6 +99,7 @@ SUBAGENT_BUILDERS_BY_NAME: dict[str, SubagentBuilder] = {
"onedrive": build_onedrive_subagent,
"reddit": build_reddit_subagent,
"tiktok": build_tiktok_subagent,
+ "walmart": build_walmart_subagent,
"web_crawler": build_web_crawler_subagent,
"youtube": build_youtube_subagent,
}
diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/test_subagent_composition.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/test_subagent_composition.py
index f578f3457..bc12f1308 100644
--- a/surfsense_backend/tests/unit/agents/multi_agent_chat/test_subagent_composition.py
+++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/test_subagent_composition.py
@@ -40,6 +40,7 @@ _EXPECTED_SUBAGENTS = frozenset(
"onedrive",
"reddit",
"tiktok",
+ "walmart",
"web_crawler",
"youtube",
}
From 56e8fa13e6ebbb88a9dda9ac62f8ff969ccfee6c Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 08:45:27 +0200
Subject: [PATCH 103/217] feat(walmart): route product-review questions to
marketplace specialists
---
.../main_agent/system_prompt/prompts/identity/private.md | 2 +-
.../main_agent/system_prompt/prompts/identity/team.md | 2 +-
.../main_agent/system_prompt/prompts/kb_first.md | 3 ++-
.../main_agent/system_prompt/prompts/routing.md | 4 +++-
4 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/identity/private.md b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/identity/private.md
index 82444634a..56e933253 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/identity/private.md
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/identity/private.md
@@ -6,7 +6,7 @@ are changing, and what is being published across the open web — and to put
that research to work alongside their own knowledge base.
You do this by dispatching **specialist subagents** via the `task` tool:
-- **Live web data** — Reddit, YouTube, Instagram, TikTok, Amazon, Google
+- **Live web data** — Reddit, YouTube, Instagram, TikTok, Amazon, Walmart, Google
Maps, Google Search, and the web crawler return structured, current
platform data (posts, comments, transcripts, videos, products, reviews,
SERPs, full page content).
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/identity/team.md b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/identity/team.md
index 38cec63dc..a16a8bfd2 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/identity/team.md
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/identity/team.md
@@ -6,7 +6,7 @@ are changing, and what is being published across the open web — and to put
that research to work alongside the team's shared knowledge base.
You do this by dispatching **specialist subagents** via the `task` tool:
-- **Live web data** — Reddit, YouTube, Instagram, TikTok, Amazon, Google
+- **Live web data** — Reddit, YouTube, Instagram, TikTok, Amazon, Walmart, Google
Maps, Google Search, and the web crawler return structured, current
platform data (posts, comments, transcripts, videos, products, reviews,
SERPs, full page content).
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/kb_first.md b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/kb_first.md
index 3bae85262..60192fb39 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/kb_first.md
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/kb_first.md
@@ -2,7 +2,8 @@
CRITICAL — ground factual answers in what you actually receive this turn:
- **live platform data** via the market specialists —
`task(reddit, ...)`, `task(youtube, ...)`, `task(instagram, ...)`,
- `task(tiktok, ...)`, `task(amazon, ...)`, `task(google_maps, ...)`,
+ `task(tiktok, ...)`, `task(amazon, ...)`, `task(walmart, ...)`,
+ `task(google_maps, ...)`,
`task(google_search, ...)`, `task(web_crawler, ...)`. Anything about
competitors, markets, rankings, reviews, or audience sentiment is answered
from what these return **this turn**, never from your training data: your
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/routing.md b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/routing.md
index fb818cabc..7e2a0f983 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/routing.md
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/routing.md
@@ -32,7 +32,9 @@ about a brand, product, or topic is answered from the platform where they
say it — `task(reddit, …)` for community discussion and threads,
`task(youtube, …)` for video content, transcripts, and comment sections,
`task(tiktok, …)` for short-form video trends by hashtag or search,
-`task(google_maps, …)` for customer reviews of physical businesses. Web
+`task(google_maps, …)` for customer reviews of physical businesses,
+`task(amazon, …)` / `task(walmart, …)` for product ratings and customer
+reviews of retail products (Walmart pages the full review history). Web
search only finds articles *about* the conversation; the platform
specialists return the conversation itself, structured and current. For
competitive questions ("what are people saying about X", "how is Y
From d7c30cac2f4a1622059199179a2117b99bed5783 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 08:45:27 +0200
Subject: [PATCH 104/217] refactor(walmart): align reviews executor docstring
---
surfsense_backend/app/capabilities/walmart/reviews/executor.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/surfsense_backend/app/capabilities/walmart/reviews/executor.py b/surfsense_backend/app/capabilities/walmart/reviews/executor.py
index 35f88e11a..ba1ee0367 100644
--- a/surfsense_backend/app/capabilities/walmart/reviews/executor.py
+++ b/surfsense_backend/app/capabilities/walmart/reviews/executor.py
@@ -13,7 +13,7 @@ ReviewsFn = Callable[..., Awaitable[list[dict]]]
def build_reviews_executor(scrape_fn: ReviewsFn | None = None) -> Executor:
- """Bind the executor to a reviews scraper fn (defaults to the proprietary actor)."""
+ """Bind the capability input mapping to a replaceable reviews scraper function."""
scrape_fn = scrape_fn or scrape_reviews
async def execute(payload: ReviewsInput) -> ReviewsOutput:
From b5cddcfb2b251d5a0f88ea929b62da6b4bfe1863 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 08:49:10 +0200
Subject: [PATCH 105/217] docs(walmart): document billing rates in env examples
---
docker/.env.example | 4 +++-
surfsense_backend/.env.example | 9 +++++++--
2 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/docker/.env.example b/docker/.env.example
index cd9789fae..a92939e4a 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -441,7 +441,7 @@ SURFSENSE_ENABLE_DOOM_LOOP=true
# WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE=3000
# Debit the credit wallet per *item returned* by the platform-native scrapers
-# (Reddit, Google Search, Google Maps, Amazon, YouTube). Default FALSE keeps scraping
+# (Reddit, Google Search, Google Maps, Amazon, Walmart, YouTube). Default FALSE keeps scraping
# effectively free for self-hosted installs. Each rate is micro-USD per item,
# config-driven: = round(USD_per_1000_items * 1_000). Defaults sit
# at/above Apify's first-party actor rates (we charge no subscription tiers,
@@ -458,6 +458,8 @@ SURFSENSE_ENABLE_DOOM_LOOP=true
# TIKTOK_MICROS_PER_VIDEO=3500
# TIKTOK_MICROS_PER_USER=2500
# TIKTOK_MICROS_PER_COMMENT=1500
+# WALMART_MICROS_PER_PRODUCT=3500
+# WALMART_MICROS_PER_REVIEW=1500
# Safety ceiling on per-call premium reservation, in micro-USD ($1.00 default).
# QUOTA_MAX_RESERVE_MICROS=1000000
diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example
index 404bd3b44..3b4a297b9 100644
--- a/surfsense_backend/.env.example
+++ b/surfsense_backend/.env.example
@@ -277,8 +277,8 @@ MICROS_PER_PAGE=1000
# WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE=3000
# Debit the credit wallet per *item returned* by the platform-native scrapers
-# (Reddit, Google Search, Google Maps, Amazon, YouTube). Default FALSE keeps
-# scraping effectively free for self-hosted/OSS installs; hosted set TRUE.
+# (Reddit, Google Search, Google Maps, Amazon, Walmart, YouTube). Default FALSE
+# keeps scraping effectively free for self-hosted/OSS installs; hosted set TRUE.
# Each rate is micro-USD per item, fully config-driven (no hardcoded rate):
# = round(USD_per_1000_items * 1_000)
# 3500 == $3.50/1000 | 5000 == $5/1000 | 2000 == $2/1000
@@ -301,6 +301,11 @@ MICROS_PER_PAGE=1000
# Browser-listing retries when a feed is empty (profile feed is withheld from
# flagged IPs; each retry draws a fresh rotating exit IP). Set to 1 for a static IP.
# TIKTOK_LISTING_MAX_ATTEMPTS=3
+# Walmart products (server-rendered JSON behind residential proxies) priced like
+# Amazon; reviews are 10/page (many light requests) so priced on the per-review
+# market like Google Maps reviews.
+# WALMART_MICROS_PER_PRODUCT=3500
+# WALMART_MICROS_PER_REVIEW=1500
# Low-balance warning threshold (micro-USD), surfaced to the UI. Default $0.50.
CREDIT_LOW_BALANCE_WARNING_MICROS=500000
From 07721bda40b9086f5cd05ba90f6a8a7d33cb9105 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 08:49:10 +0200
Subject: [PATCH 106/217] test(walmart): cover capability registry, schemas,
and executors for both verbs
---
.../unit/capabilities/walmart/__init__.py | 0
.../capabilities/walmart/reviews/__init__.py | 0
.../walmart/reviews/test_executor.py | 38 ++++++++++++
.../walmart/reviews/test_schemas.py | 37 ++++++++++++
.../capabilities/walmart/scrape/__init__.py | 0
.../walmart/scrape/test_executor.py | 42 ++++++++++++++
.../walmart/scrape/test_schemas.py | 58 +++++++++++++++++++
.../capabilities/walmart/test_registry.py | 22 +++++++
8 files changed, 197 insertions(+)
create mode 100644 surfsense_backend/tests/unit/capabilities/walmart/__init__.py
create mode 100644 surfsense_backend/tests/unit/capabilities/walmart/reviews/__init__.py
create mode 100644 surfsense_backend/tests/unit/capabilities/walmart/reviews/test_executor.py
create mode 100644 surfsense_backend/tests/unit/capabilities/walmart/reviews/test_schemas.py
create mode 100644 surfsense_backend/tests/unit/capabilities/walmart/scrape/__init__.py
create mode 100644 surfsense_backend/tests/unit/capabilities/walmart/scrape/test_executor.py
create mode 100644 surfsense_backend/tests/unit/capabilities/walmart/scrape/test_schemas.py
create mode 100644 surfsense_backend/tests/unit/capabilities/walmart/test_registry.py
diff --git a/surfsense_backend/tests/unit/capabilities/walmart/__init__.py b/surfsense_backend/tests/unit/capabilities/walmart/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/capabilities/walmart/reviews/__init__.py b/surfsense_backend/tests/unit/capabilities/walmart/reviews/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/capabilities/walmart/reviews/test_executor.py b/surfsense_backend/tests/unit/capabilities/walmart/reviews/test_executor.py
new file mode 100644
index 000000000..d9d325780
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/walmart/reviews/test_executor.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+from app.capabilities.walmart.reviews.executor import build_reviews_executor
+from app.capabilities.walmart.reviews.schemas import ReviewsInput
+from app.proprietary.platforms.walmart import WalmartReviewsInput
+
+
+class _FakeScraper:
+ def __init__(self) -> None:
+ self.calls: list[tuple[WalmartReviewsInput, int | None]] = []
+
+ async def __call__(
+ self, input_model: WalmartReviewsInput, *, limit: int | None = None
+ ) -> list[dict]:
+ self.calls.append((input_model, limit))
+ return [{"reviewId": "r1", "rating": 5}]
+
+
+async def test_executor_maps_agent_input_to_scraper_input():
+ scraper = _FakeScraper()
+ execute = build_reviews_executor(scraper)
+
+ output = await execute(
+ ReviewsInput(
+ urls=["https://www.walmart.com/ip/123456"],
+ item_ids=["222"],
+ max_reviews=50,
+ sort_by="most-helpful",
+ )
+ )
+
+ assert output.items[0].reviewId == "r1"
+ input_model, limit = scraper.calls[0]
+ assert input_model.itemIds == ["https://www.walmart.com/ip/123456", "222"]
+ assert input_model.maxReviews == 50
+ assert input_model.sort == "most-helpful"
+ # limit is the pre-flight worst case: 2 sources * 50 reviews
+ assert limit == 100
diff --git a/surfsense_backend/tests/unit/capabilities/walmart/reviews/test_schemas.py b/surfsense_backend/tests/unit/capabilities/walmart/reviews/test_schemas.py
new file mode 100644
index 000000000..2d35ab191
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/walmart/reviews/test_schemas.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+import pytest
+from pydantic import ValidationError
+
+from app.capabilities.walmart.reviews.schemas import ReviewsInput, ReviewsOutput
+
+
+def test_estimated_units_scale_with_sources_and_max_reviews():
+ payload = ReviewsInput(
+ urls=["https://www.walmart.com/ip/1"], item_ids=["222"], max_reviews=150
+ )
+
+ # 2 sources * 150 reviews each
+ assert payload.estimated_units == 300
+
+
+def test_at_least_one_source_is_required():
+ with pytest.raises(ValidationError):
+ ReviewsInput()
+
+
+def test_sources_merge_urls_then_item_ids():
+ payload = ReviewsInput(urls=["https://www.walmart.com/ip/1"], item_ids=["222"])
+
+ assert payload.sources() == ["https://www.walmart.com/ip/1", "222"]
+
+
+def test_error_items_are_not_billable():
+ output = ReviewsOutput(
+ items=[
+ {"reviewId": "r1", "rating": 5},
+ {"error": "reviews_not_found"},
+ ]
+ )
+
+ assert output.billable_units == 1
diff --git a/surfsense_backend/tests/unit/capabilities/walmart/scrape/__init__.py b/surfsense_backend/tests/unit/capabilities/walmart/scrape/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/capabilities/walmart/scrape/test_executor.py b/surfsense_backend/tests/unit/capabilities/walmart/scrape/test_executor.py
new file mode 100644
index 000000000..e31e4b638
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/walmart/scrape/test_executor.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from app.capabilities.walmart.scrape.executor import build_scrape_executor
+from app.capabilities.walmart.scrape.schemas import MAX_WALMART_RESULTS, ScrapeInput
+from app.proprietary.platforms.walmart import WalmartScrapeInput
+
+
+class _FakeScraper:
+ def __init__(self) -> None:
+ self.calls: list[tuple[WalmartScrapeInput, int | None]] = []
+
+ async def __call__(
+ self, input_model: WalmartScrapeInput, *, limit: int | None = None
+ ) -> list[dict]:
+ self.calls.append((input_model, limit))
+ return [{"usItemId": "123", "name": "Product"}]
+
+
+async def test_executor_maps_agent_input_to_scraper_input():
+ scraper = _FakeScraper()
+ execute = build_scrape_executor(scraper)
+
+ output = await execute(
+ ScrapeInput(
+ search_terms=["air fryer"],
+ urls=["https://www.walmart.com/ip/123456"],
+ max_items=5,
+ include_details=False,
+ include_reviews_sample=False,
+ )
+ )
+
+ assert output.items[0].usItemId == "123"
+ input_model, limit = scraper.calls[0]
+ assert input_model.startUrls == [
+ "https://www.walmart.com/ip/123456",
+ "https://www.walmart.com/search?q=air+fryer",
+ ]
+ assert input_model.maxItemsPerStartUrl == 5
+ assert input_model.includeDetails is False
+ assert input_model.includeReviewsSample is False
+ assert limit == MAX_WALMART_RESULTS
diff --git a/surfsense_backend/tests/unit/capabilities/walmart/scrape/test_schemas.py b/surfsense_backend/tests/unit/capabilities/walmart/scrape/test_schemas.py
new file mode 100644
index 000000000..c7ce4ff9e
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/walmart/scrape/test_schemas.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import pytest
+from pydantic import ValidationError
+
+from app.capabilities.walmart.scrape.schemas import (
+ MAX_WALMART_RESULTS,
+ ScrapeInput,
+ ScrapeOutput,
+)
+
+
+def test_estimated_units_cover_search_and_direct_sources():
+ payload = ScrapeInput(
+ search_terms=["air fryer", "blender"],
+ urls=["https://www.walmart.com/ip/123456"],
+ max_items=20,
+ )
+
+ # (2 search + 1 direct source) * 20 items each
+ assert payload.estimated_units == 60
+
+
+def test_estimated_units_respect_hard_run_ceiling():
+ payload = ScrapeInput(search_terms=["x"] * 20, max_items=100)
+ assert payload.estimated_units == MAX_WALMART_RESULTS
+
+
+def test_at_least_one_source_is_required():
+ with pytest.raises(ValidationError):
+ ScrapeInput()
+
+
+def test_combined_sources_are_capped():
+ with pytest.raises(ValidationError):
+ ScrapeInput(urls=["u"] * 11, search_terms=["t"] * 10)
+
+
+def test_start_urls_synthesize_a_search_url_per_term():
+ payload = ScrapeInput(
+ urls=["https://www.walmart.com/ip/1"], search_terms=["air fryer"]
+ )
+
+ assert payload.start_urls() == [
+ "https://www.walmart.com/ip/1",
+ "https://www.walmart.com/search?q=air+fryer",
+ ]
+
+
+def test_error_items_are_not_billable():
+ output = ScrapeOutput(
+ items=[
+ {"usItemId": "123", "name": "Product"},
+ {"error": "product_not_found", "errorDescription": "Missing"},
+ ]
+ )
+
+ assert output.billable_units == 1
diff --git a/surfsense_backend/tests/unit/capabilities/walmart/test_registry.py b/surfsense_backend/tests/unit/capabilities/walmart/test_registry.py
new file mode 100644
index 000000000..647ad7976
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/walmart/test_registry.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from app.capabilities.core import BillingUnit
+from app.capabilities.core.store import get_capability
+from app.capabilities.walmart.reviews.schemas import ReviewsInput, ReviewsOutput
+from app.capabilities.walmart.scrape.schemas import ScrapeInput, ScrapeOutput
+
+
+def test_walmart_scrape_is_registered_and_metered():
+ capability = get_capability("walmart.scrape")
+
+ assert capability.input_schema is ScrapeInput
+ assert capability.output_schema is ScrapeOutput
+ assert capability.billing_unit is BillingUnit.WALMART_PRODUCT
+
+
+def test_walmart_reviews_is_registered_and_metered():
+ capability = get_capability("walmart.reviews")
+
+ assert capability.input_schema is ReviewsInput
+ assert capability.output_schema is ReviewsOutput
+ assert capability.billing_unit is BillingUnit.WALMART_REVIEW
From 1c50a33647ec08549ced4ca43d375d74e2b4b464 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 09:01:04 +0200
Subject: [PATCH 107/217] feat(walmart): add playground catalog entry and icon
---
surfsense_web/lib/playground/catalog.ts | 10 ++++++++++
surfsense_web/lib/playground/platform-icons.tsx | 1 +
surfsense_web/public/connectors/walmart.svg | 11 +++++++++++
3 files changed, 22 insertions(+)
create mode 100644 surfsense_web/public/connectors/walmart.svg
diff --git a/surfsense_web/lib/playground/catalog.ts b/surfsense_web/lib/playground/catalog.ts
index d1c1af5cb..c32a24a20 100644
--- a/surfsense_web/lib/playground/catalog.ts
+++ b/surfsense_web/lib/playground/catalog.ts
@@ -6,6 +6,7 @@ import {
InstagramIcon,
RedditIcon,
TikTokIcon,
+ WalmartIcon,
WebIcon,
YouTubeIcon,
} from "./platform-icons";
@@ -96,6 +97,15 @@ export const PLAYGROUND_PLATFORMS: PlaygroundPlatform[] = [
icon: AmazonIcon,
verbs: [{ name: "amazon.scrape", verb: "scrape", label: "Scrape" }],
},
+ {
+ id: "walmart",
+ label: "Walmart",
+ icon: WalmartIcon,
+ verbs: [
+ { name: "walmart.scrape", verb: "scrape", label: "Scrape" },
+ { name: "walmart.reviews", verb: "reviews", label: "Reviews" },
+ ],
+ },
{
id: "web",
label: "Web",
diff --git a/surfsense_web/lib/playground/platform-icons.tsx b/surfsense_web/lib/playground/platform-icons.tsx
index dfa1a828d..c0afc91ef 100644
--- a/surfsense_web/lib/playground/platform-icons.tsx
+++ b/surfsense_web/lib/playground/platform-icons.tsx
@@ -23,6 +23,7 @@ function brandIcon(src: string, alt: string) {
}
export const AmazonIcon = brandIcon("/connectors/amazon.svg", "Amazon");
+export const WalmartIcon = brandIcon("/connectors/walmart.svg", "Walmart");
export const RedditIcon = brandIcon("/connectors/reddit.svg", "Reddit");
export const YouTubeIcon = brandIcon("/connectors/youtube.svg", "YouTube");
export const InstagramIcon = brandIcon("/connectors/instagram.svg", "Instagram");
diff --git a/surfsense_web/public/connectors/walmart.svg b/surfsense_web/public/connectors/walmart.svg
new file mode 100644
index 000000000..dc334ef22
--- /dev/null
+++ b/surfsense_web/public/connectors/walmart.svg
@@ -0,0 +1,11 @@
+
+ Walmart
+
+
+
+
+
+
+
+
+
From 2a512e0015ca8ad28f18111fcd4b361e78b75fa0 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 09:01:11 +0200
Subject: [PATCH 108/217] feat(walmart): add connector marketing page
---
.../lib/connectors-marketing/index.ts | 2 +
.../lib/connectors-marketing/walmart.tsx | 273 ++++++++++++++++++
2 files changed, 275 insertions(+)
create mode 100644 surfsense_web/lib/connectors-marketing/walmart.tsx
diff --git a/surfsense_web/lib/connectors-marketing/index.ts b/surfsense_web/lib/connectors-marketing/index.ts
index 7d79efd9b..888b9852a 100644
--- a/surfsense_web/lib/connectors-marketing/index.ts
+++ b/surfsense_web/lib/connectors-marketing/index.ts
@@ -5,6 +5,7 @@ import { instagram } from "./instagram";
import { reddit } from "./reddit";
import { tiktok } from "./tiktok";
import type { ConnectorPageContent } from "./types";
+import { walmart } from "./walmart";
import { webCrawl } from "./web-crawl";
import { youtube } from "./youtube";
@@ -19,6 +20,7 @@ const CONNECTOR_LIST: ConnectorPageContent[] = [
googleMaps,
googleSearch,
amazon,
+ walmart,
webCrawl,
];
diff --git a/surfsense_web/lib/connectors-marketing/walmart.tsx b/surfsense_web/lib/connectors-marketing/walmart.tsx
new file mode 100644
index 000000000..e959e2638
--- /dev/null
+++ b/surfsense_web/lib/connectors-marketing/walmart.tsx
@@ -0,0 +1,273 @@
+import { IconBrandWalmart } from "@tabler/icons-react";
+import type { ConnectorPageContent } from "./types";
+
+export const walmart: ConnectorPageContent = {
+ slug: "walmart",
+ name: "Walmart",
+ cardTitle: "Walmart Product & Review API",
+ icon: IconBrandWalmart,
+
+ metaTitle: "Walmart Product & Review API | SurfSense",
+ metaDescription:
+ "Scrape public Walmart product data and deep customer reviews as structured JSON: prices, ratings, sellers, variants, availability, and full review history. Start free.",
+ keywords: [
+ "walmart product api",
+ "walmart scraper api",
+ "walmart review scraper",
+ "walmart reviews api",
+ "scrape walmart product data",
+ "walmart price scraper",
+ "walmart product data api",
+ "walmart price tracking api",
+ "walmart competitor monitoring",
+ "walmart seller data",
+ "walmart marketplace api",
+ "ecommerce product api",
+ ],
+
+ h1: "Walmart Product and Review API",
+ heroLede:
+ "The SurfSense Walmart API scrapes public Walmart.com listings as structured JSON: price and list price, rating and review count, availability, 1P and marketplace sellers, variants, and specifications. A second verb pages the full public review history — ratings, text, authors, verified-purchase flags, images, and seller responses. Point your AI agents at a search term or product URL — no login, only public data.",
+
+ transcript: {
+ prompt: "Pull the ratings and top complaints for this Walmart air fryer",
+ toolCall:
+ 'walmart.reviews({ urls: ["walmart.com/ip/..."],\n max_reviews: 500, sort_by: "most-recent" })',
+ rows: [
+ {
+ primary: "Ninja 4-Qt Air Fryer — $79.00 (was $99.00)",
+ secondary: "4.6 stars · 8,204 reviews · In Stock",
+ tag: "-20%",
+ },
+ {
+ primary: "512 reviews paged, 71% verified purchase",
+ secondary: "sorted most-recent · 132 with photos",
+ tag: "reviews",
+ },
+ {
+ primary: 'Top complaint: "basket coating flaking after 3 months"',
+ secondary: "surfaced across 24 recent 1-2 star reviews",
+ tag: "theme",
+ },
+ ],
+ resultSummary: "1 product · 512 reviews · surfaced in 6.4s",
+ },
+
+ extractIntro:
+ "Give the scraper Walmart product, search, category, or browse URLs, or plain search terms. The scrape verb returns product cards and full detail with a free on-page review sample; the reviews verb pages the complete public review history for any product. Walmart data is US marketplace (walmart.com), server-rendered as JSON for stability.",
+ extractFields: [
+ {
+ label: "Product core",
+ description:
+ "Name, item id (usItemId), brand, price, list price, availability, in-stock flag, and canonical URL for every product.",
+ },
+ {
+ label: "Ratings & review sample",
+ description:
+ "Average stars and total review count on every product, plus a free sample of on-page reviews when you fetch full detail.",
+ },
+ {
+ label: "Deep reviews",
+ description:
+ "The reviews verb pages the full public review history: rating, title, text, author, date, verified-purchase flag, helpful votes, images, and seller responses.",
+ },
+ {
+ label: "Sellers",
+ description:
+ "The seller behind each product, typed as Walmart first-party (1P) or third-party marketplace (3P), with id and name.",
+ },
+ {
+ label: "Variants & media",
+ description:
+ "Product variants (color, size, count) with their own item ids and prices, plus the gallery and thumbnail images.",
+ },
+ {
+ label: "Content & taxonomy",
+ description:
+ "Short and long descriptions, specifications, category, and breadcrumb trail for catalog enrichment and classification.",
+ },
+ ],
+
+ useCasesHeading: "What teams do with the Walmart API",
+ useCases: [
+ {
+ title: "Review mining and product research",
+ description:
+ "Page the full review history for a product and brief an agent to cluster complaints, track sentiment over time, and separate verified-purchase signal from noise. Walmart exposes far more review depth than most marketplaces — use it to inform your roadmap or listing copy.",
+ },
+ {
+ title: "Price and buy-box tracking",
+ description:
+ "Watch your own and competitors' prices, list prices, and which seller wins the offer (Walmart 1P vs a third-party marketplace seller). Run the same URLs on a schedule and diff the results to alert on undercuts and stockouts.",
+ },
+ {
+ title: "Seller and marketplace intelligence",
+ description:
+ "See which third-party sellers are winning offers on the products that matter to you, and how first-party Walmart pricing compares, to size the marketplace opportunity in your category.",
+ },
+ {
+ title: "Catalog and assortment enrichment",
+ description:
+ "Enrich a catalog by item id with structured attributes, variants, specifications, and images, and discover products from search or category URLs to map an entire assortment.",
+ },
+ ],
+
+ comparison: {
+ heading: "A Walmart scraper API built for agents",
+ intro:
+ "Most Walmart data APIs bill per request, split product and review data across endpoints, and leave the discovery-to-detail logic to you. Here is how SurfSense compares.",
+ columnLabel: "Typical Walmart data API",
+ rows: [
+ {
+ feature: "Pricing",
+ official: "Per-request pricing that climbs fast at scale",
+ surfsense: "Pay per product or per review returned, with a free tier to start",
+ },
+ {
+ feature: "Discovery",
+ official: "Separate search and detail endpoints you stitch together",
+ surfsense: "One verb takes search terms or product, search, category, and browse URLs",
+ },
+ {
+ feature: "Reviews",
+ official: "A separate paid endpoint, often capped shallow",
+ surfsense: "A dedicated verb pages the full public review history with photos and seller replies",
+ },
+ {
+ feature: "Data access",
+ official: "Requires accounts, keys, or approval for many providers",
+ surfsense: "Public, anonymous data only — no login or seller account",
+ },
+ {
+ feature: "Agent-ready",
+ official: "No; you wire the harness yourself",
+ surfsense: "MCP server exposes walmart.scrape and walmart.reviews as native tools",
+ },
+ ],
+ },
+
+ api: {
+ platform: "walmart",
+ verb: "scrape",
+ mcpTool: "walmart.scrape",
+ requestBody: {
+ search_terms: ["air fryer"],
+ max_items: 5,
+ include_reviews_sample: true,
+ },
+ },
+
+ schema: {
+ requestNote:
+ "walmart.scrape: provide urls or search_terms (at least one), up to 20 combined sources per call. For the full review history, use the walmart.reviews verb with product urls or item_ids.",
+ request: [
+ {
+ name: "urls",
+ type: "string[]",
+ description:
+ "Walmart product (/ip/), search (/search), category (/cp/), or browse (/browse/) URLs. Provide urls or search_terms.",
+ },
+ {
+ name: "search_terms",
+ type: "string[]",
+ description:
+ "Search phrases run on walmart.com, e.g. 'air fryer'. Provide search_terms or urls.",
+ },
+ {
+ name: "max_items",
+ type: "integer",
+ defaultValue: "10",
+ description: "Max products per search term or category/browse URL, 1 to 100.",
+ },
+ {
+ name: "include_details",
+ type: "boolean",
+ defaultValue: "true",
+ description: "Fetch full product detail pages; false returns faster card-only results.",
+ },
+ {
+ name: "include_reviews_sample",
+ type: "boolean",
+ defaultValue: "true",
+ description:
+ "Attach the free on-page review sample from each detail page. For full history use walmart.reviews.",
+ },
+ ],
+ responseNote:
+ "The response is { items: [...] } with one item per product. One returned product is one billable unit; error items are never billed.",
+ response: [
+ {
+ name: "name / usItemId / brand",
+ type: "string",
+ description: "Product identity: display name, Walmart item id, and brand.",
+ },
+ {
+ name: "price / listPrice",
+ type: "object",
+ description: "Current price and strike-through list price, each with value and currency.",
+ },
+ {
+ name: "stars / reviewsCount",
+ type: "number",
+ description: "Average star rating and total public review count.",
+ },
+ {
+ name: "seller",
+ type: "object",
+ description: "Offer seller with id, name, and type (WALMART 1P or MARKETPLACE 3P).",
+ },
+ {
+ name: "availabilityStatus / inStock",
+ type: "string",
+ description: "Availability label and a boolean in-stock flag.",
+ },
+ {
+ name: "variants",
+ type: "object[]",
+ description: "Product variants (color, size, count) with their own item ids and prices.",
+ },
+ {
+ name: "reviewsSample",
+ type: "object",
+ description: "Free sample of on-page reviews returned with full detail pages.",
+ },
+ ],
+ },
+
+ faq: [
+ {
+ question: "What is the Walmart product API?",
+ answer:
+ "It returns a Walmart listing's data as structured JSON instead of raw HTML. The SurfSense Walmart API scrapes public product pages and gives you price, rating, seller, variants, availability, and specifications as clean JSON your agents can read.",
+ },
+ {
+ question: "Can I get the full Walmart review history?",
+ answer:
+ "Yes. The walmart.reviews verb pages the complete public review history for a product — rating, title, text, author, date, verified-purchase flag, helpful votes, images, and seller responses. Pass product URLs or item ids and set max_reviews and sort_by.",
+ },
+ {
+ question: "Does it only use public data?",
+ answer:
+ "It does. The scraper collects public, anonymous Walmart data only — no login or seller account. That covers product details, prices, ratings, public reviews, seller identity, variants, and availability.",
+ },
+ {
+ question: "How do I look up a product by item id or search term?",
+ answer:
+ "Pass a product URL (which contains the item id) or a phrase in search_terms to discover products. You can also pass search, category, and browse URLs. walmart.scrape handles discovery; walmart.reviews takes urls or item_ids directly.",
+ },
+ {
+ question: "Which Walmart marketplace is supported?",
+ answer:
+ "The scraper targets the US marketplace at walmart.com. Data is read from the server-rendered JSON Walmart ships in the page, which keeps extraction stable against the frequent front-end A/B testing on the site.",
+ },
+ ],
+
+ related: [
+ { label: "Amazon Product API", href: "/amazon" },
+ { label: "Google Maps API", href: "/google-maps" },
+ { label: "Google Search API", href: "/google-search" },
+ { label: "Reddit API", href: "/reddit" },
+ { label: "SurfSense MCP Server", href: "/mcp-server" },
+ { label: "Read the docs", href: "/docs" },
+ ],
+};
From 6c98bd33a83e4656818c245427a1d9c4801b93b0 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 09:01:11 +0200
Subject: [PATCH 109/217] docs(walmart): add connector docs and list in indexes
---
.../content/docs/connectors/index.mdx | 2 +-
.../content/docs/connectors/native/index.mdx | 7 +-
.../content/docs/connectors/native/meta.json | 1 +
.../docs/connectors/native/walmart.mdx | 71 +++++++++++++++++++
.../content/docs/how-to/mcp-server.mdx | 2 +-
5 files changed, 80 insertions(+), 3 deletions(-)
create mode 100644 surfsense_web/content/docs/connectors/native/walmart.mdx
diff --git a/surfsense_web/content/docs/connectors/index.mdx b/surfsense_web/content/docs/connectors/index.mdx
index 04241f79f..eb3f28052 100644
--- a/surfsense_web/content/docs/connectors/index.mdx
+++ b/surfsense_web/content/docs/connectors/index.mdx
@@ -12,7 +12,7 @@ Connectors bring data into SurfSense — either as searchable knowledge or as li
}
title="Native Connectors"
- description="SurfSense's built-in scraper APIs: Reddit, YouTube, Instagram, TikTok, Amazon, Google Maps, Google Search, and Web Crawl — usable in chat, the API Playground, or via REST"
+ description="SurfSense's built-in scraper APIs: Reddit, YouTube, Instagram, TikTok, Amazon, Walmart, Google Maps, Google Search, and Web Crawl — usable in chat, the API Playground, or via REST"
href="/docs/connectors/native"
/>
+
Date: Sun, 19 Jul 2026 09:01:18 +0200
Subject: [PATCH 110/217] feat(walmart): list walmart across marketing surfaces
and example prompts
---
surfsense_web/app/(home)/mcp-server/page.tsx | 12 +++++++-----
surfsense_web/components/homepage/hero-section.tsx | 4 ++--
surfsense_web/components/homepage/persona-paths.tsx | 2 +-
surfsense_web/components/seo/json-ld.tsx | 4 ++--
surfsense_web/lib/chat/example-prompts.ts | 1 +
5 files changed, 13 insertions(+), 10 deletions(-)
diff --git a/surfsense_web/app/(home)/mcp-server/page.tsx b/surfsense_web/app/(home)/mcp-server/page.tsx
index ab7769ee9..439e1ae6f 100644
--- a/surfsense_web/app/(home)/mcp-server/page.tsx
+++ b/surfsense_web/app/(home)/mcp-server/page.tsx
@@ -16,7 +16,7 @@ import type { FaqItem } from "@/lib/connectors-marketing/types";
const canonicalUrl = "https://www.surfsense.com/mcp-server";
const metaDescription =
- "The SurfSense MCP server gives Claude, Cursor, and any MCP client native tools for your workspace: scrape Reddit, YouTube, Instagram, TikTok, Amazon, Google Maps, Google Search, and the web, plus full knowledge base access. One API key.";
+ "The SurfSense MCP server gives Claude, Cursor, and any MCP client native tools for your workspace: scrape Reddit, YouTube, Instagram, TikTok, Amazon, Walmart, Google Maps, Google Search, and the web, plus full knowledge base access. One API key.";
export const metadata: Metadata = {
title: "SurfSense MCP Server: Scraper APIs and Knowledge Base as Agent Tools",
@@ -103,6 +103,8 @@ const TOOL_GROUPS = [
"surfsense_google_maps_reviews",
"surfsense_google_search",
"surfsense_amazon_scrape",
+ "surfsense_walmart_scrape",
+ "surfsense_walmart_reviews",
"surfsense_web_crawl",
"surfsense_list_scraper_runs",
"surfsense_get_scraper_run",
@@ -134,7 +136,7 @@ const FAQ: FaqItem[] = [
{
question: "What is the SurfSense MCP server?",
answer:
- "It is a Model Context Protocol server that exposes your SurfSense workspace to MCP clients like Claude Code, Cursor, and Claude Desktop. Your agents get native tools for every scraper API (Reddit, YouTube, Instagram, TikTok, Amazon, Google Maps, Google Search, web crawl) and for searching, reading, and writing your knowledge base.",
+ "It is a Model Context Protocol server that exposes your SurfSense workspace to MCP clients like Claude Code, Cursor, and Claude Desktop. Your agents get native tools for every scraper API (Reddit, YouTube, Instagram, TikTok, Amazon, Walmart, Google Maps, Google Search, web crawl) and for searching, reading, and writing your knowledge base.",
},
{
question: "Which MCP clients does it work with?",
@@ -222,9 +224,9 @@ export default function McpServerPage() {
The SurfSense MCP server hands Claude, Cursor, or any MCP client the whole platform:
- scrape Reddit, YouTube, Instagram, TikTok, Amazon, Google Maps, Google Search, and
- the open web, and search, read, and write your knowledge base. One API key, typed
- tools, pay as you go.
+ scrape Reddit, YouTube, Instagram, TikTok, Amazon, Walmart, Google Maps, Google
+ Search, and the open web, and search, read, and write your knowledge base. One API
+ key, typed tools, pay as you go.
diff --git a/surfsense_web/components/homepage/hero-section.tsx b/surfsense_web/components/homepage/hero-section.tsx
index 86adfd5b0..0f46699b1 100644
--- a/surfsense_web/components/homepage/hero-section.tsx
+++ b/surfsense_web/components/homepage/hero-section.tsx
@@ -658,8 +658,8 @@ export function HeroSection() {
>
SurfSense is the open-source NotebookLM alternative for AI agents, an open web
research platform with live data connectors. Your AI agents research the live web with
- structured data from Reddit, YouTube, Instagram, TikTok, Amazon, Google Maps, Google
- Search, and any page on the open web.
+ structured data from Reddit, YouTube, Instagram, TikTok, Amazon, Walmart, Google
+ Maps, Google Search, and any page on the open web.
diff --git a/surfsense_web/components/homepage/persona-paths.tsx b/surfsense_web/components/homepage/persona-paths.tsx
index 78f9699f3..2e2ff3614 100644
--- a/surfsense_web/components/homepage/persona-paths.tsx
+++ b/surfsense_web/components/homepage/persona-paths.tsx
@@ -35,7 +35,7 @@ const PATHS: {
eyebrow: "For developers & agents",
title: "The whole platform is programmable",
description:
- "Everything SurfSense agents can do is a typed REST API: scrape Reddit, YouTube, TikTok, Amazon, Google Maps, Google Search, and the open web, search the knowledge base, run automations. One key, JSON in and out, $5 free credit, pay as you go. Already running agents in Claude, Cursor, or your own harness? The SurfSense MCP server hands them the same tools natively.",
+ "Everything SurfSense agents can do is a typed REST API: scrape Reddit, YouTube, TikTok, Amazon, Walmart, Google Maps, Google Search, and the open web, search the knowledge base, run automations. One key, JSON in and out, $5 free credit, pay as you go. Already running agents in Claude, Cursor, or your own harness? The SurfSense MCP server hands them the same tools natively.",
links: [
{ label: "Read the docs", href: "/docs" },
{ label: "SurfSense MCP server", href: "/mcp-server" },
diff --git a/surfsense_web/components/seo/json-ld.tsx b/surfsense_web/components/seo/json-ld.tsx
index 43b4371e4..366a6bc2a 100644
--- a/surfsense_web/components/seo/json-ld.tsx
+++ b/surfsense_web/components/seo/json-ld.tsx
@@ -76,11 +76,11 @@ export function SoftwareApplicationJsonLd() {
"Free self-hosted from the open-source repo; cloud starts with $5 of free credit, then pay as you go",
},
description:
- "SurfSense is an open-source NotebookLM alternative for AI agents. It researches the live web with platform-native connectors for Reddit, YouTube, TikTok, Amazon, Google Maps, Google Search, and any page on the open web, through one API or MCP server.",
+ "SurfSense is an open-source NotebookLM alternative for AI agents. It researches the live web with platform-native connectors for Reddit, YouTube, TikTok, Amazon, Walmart, Google Maps, Google Search, and any page on the open web, through one API or MCP server.",
url: "https://www.surfsense.com",
downloadUrl: "https://github.com/MODSetter/SurfSense/releases",
featureList: [
- "Platform-native connectors: Reddit, YouTube, TikTok, Amazon, Google Maps, Google Search, Web Crawl",
+ "Platform-native connectors: Reddit, YouTube, TikTok, Amazon, Walmart, Google Maps, Google Search, Web Crawl",
"MCP server that exposes every connector as a native agent tool",
"Agent harness with retries, structured output, and credit metering",
"Live web research with cited briefs and alerts",
diff --git a/surfsense_web/lib/chat/example-prompts.ts b/surfsense_web/lib/chat/example-prompts.ts
index 8bec7f699..86cdf099d 100644
--- a/surfsense_web/lib/chat/example-prompts.ts
+++ b/surfsense_web/lib/chat/example-prompts.ts
@@ -37,6 +37,7 @@ export const CHAT_EXAMPLE_CATEGORIES: ChatExampleCategory[] = [
"Analyze the comments on [channel]'s last 10 videos and cluster the complaints",
"What is Reddit saying about [topic] this week?",
"Pull the top TikTok videos for [hashtag] and summarize the trend",
+ "Summarize the top complaints in the Walmart reviews for [product]",
],
},
{
From f6970b90e812548b1df7e8e0b66fd7895eba3784 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Sun, 19 Jul 2026 09:10:03 +0200
Subject: [PATCH 111/217] fix(walmart): parse category breadcrumb path into
breadCrumbs and category
---
.../proprietary/platforms/walmart/parsers.py | 17 ++++++++++++++++-
.../platforms/walmart/fixtures/product.html | 2 +-
.../unit/platforms/walmart/test_parsers.py | 3 +++
3 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/surfsense_backend/app/proprietary/platforms/walmart/parsers.py b/surfsense_backend/app/proprietary/platforms/walmart/parsers.py
index 9a0d070a1..5ea32c4d1 100644
--- a/surfsense_backend/app/proprietary/platforms/walmart/parsers.py
+++ b/surfsense_backend/app/proprietary/platforms/walmart/parsers.py
@@ -122,6 +122,19 @@ def _images(image_info: dict[str, Any]) -> list[str]:
return images
+def _breadcrumbs(product: dict[str, Any]) -> list[str]:
+ """Category breadcrumb names, root→leaf.
+
+ Walmart ships ``category.path`` as a list of ``{name, url}`` nodes, e.g.
+ ``[{"name": "Home Improvement"}, ..., {"name": "Air Conditioners"}]`` — not
+ a string.
+ """
+ path = dig(product, "category", "path")
+ if not isinstance(path, list):
+ return []
+ return [node["name"] for node in path if isinstance(node, dict) and node.get("name")]
+
+
def _reviews_sample(
reviews: dict[str, Any] | None, limit: int = 10
) -> dict[str, Any] | None:
@@ -151,6 +164,7 @@ def parse_product(
price_info = product.get("priceInfo") or {}
image_info = product.get("imageInfo") or {}
availability = product.get("availabilityStatus")
+ breadcrumbs = _breadcrumbs(product)
fields: dict[str, Any] = {
"usItemId": str(product.get("usItemId") or product.get("id") or "") or None,
@@ -172,7 +186,8 @@ def parse_product(
else None,
"thumbnailImage": image_info.get("thumbnailUrl"),
"images": _images(image_info),
- "category": dig(product, "category", "path"),
+ "category": breadcrumbs[-1] if breadcrumbs else None,
+ "breadCrumbs": breadcrumbs,
"variants": product.get("variantCriteria") or [],
}
if include_reviews_sample:
diff --git a/surfsense_backend/tests/unit/platforms/walmart/fixtures/product.html b/surfsense_backend/tests/unit/platforms/walmart/fixtures/product.html
index 3db17b152..183ddff6b 100644
--- a/surfsense_backend/tests/unit/platforms/walmart/fixtures/product.html
+++ b/surfsense_backend/tests/unit/platforms/walmart/fixtures/product.html
@@ -1,3 +1,3 @@
Midea AC
-
+
diff --git a/surfsense_backend/tests/unit/platforms/walmart/test_parsers.py b/surfsense_backend/tests/unit/platforms/walmart/test_parsers.py
index da66064ab..596131a2e 100644
--- a/surfsense_backend/tests/unit/platforms/walmart/test_parsers.py
+++ b/surfsense_backend/tests/unit/platforms/walmart/test_parsers.py
@@ -35,6 +35,9 @@ def test_product_parser_extracts_core_fields_and_review_sample():
"https://i5.walmartimages.com/1.jpg",
"https://i5.walmartimages.com/2.jpg",
]
+ # Walmart ships category.path as breadcrumb objects, not a string.
+ assert item["breadCrumbs"] == ["Home", "Air Conditioners"]
+ assert item["category"] == "Air Conditioners"
assert item["reviewsSample"]["totalReviewCount"] == 6287
assert item["reviewsSample"]["topReviews"][0]["verifiedPurchase"] is True
From 444839b0ec3c02f367dfc4b9e14d932501230cd9 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:58:52 +0200
Subject: [PATCH 112/217] add OKF type mapping
---
.../app/services/okf/type_mapping.py | 124 ++++++++++++++++++
1 file changed, 124 insertions(+)
create mode 100644 surfsense_backend/app/services/okf/type_mapping.py
diff --git a/surfsense_backend/app/services/okf/type_mapping.py b/surfsense_backend/app/services/okf/type_mapping.py
new file mode 100644
index 000000000..2f1155369
--- /dev/null
+++ b/surfsense_backend/app/services/okf/type_mapping.py
@@ -0,0 +1,124 @@
+"""Map SurfSense document types to OKF concept ``type`` strings and ``resource`` URIs.
+
+OKF (Open Knowledge Format v0.1) requires a non-empty, human-friendly ``type`` on
+every concept and recommends a ``resource`` URI pointing at the underlying asset.
+SurfSense stores the type as a :class:`~app.db.DocumentType` enum and the source
+URL (when one exists) inside the free-form ``document_metadata`` JSON, under keys
+that differ per connector. This module is the single place that knows both mappings.
+
+Spec: https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from app.db import DocumentType
+
+# Title-cased type strings per Google's sample bundles. Types absent here fall
+# back to a derived string (see ``okf_type``), so this need not be exhaustive.
+OKF_TYPE_BY_DOCUMENT_TYPE: dict[DocumentType, str] = {
+ DocumentType.EXTENSION: "Web Page",
+ DocumentType.CRAWLED_URL: "Web Page",
+ DocumentType.FILE: "File",
+ DocumentType.NOTE: "Note",
+ DocumentType.SLACK_CONNECTOR: "Slack Message",
+ DocumentType.TEAMS_CONNECTOR: "Teams Message",
+ DocumentType.ONEDRIVE_FILE: "OneDrive File",
+ DocumentType.NOTION_CONNECTOR: "Notion Page",
+ DocumentType.YOUTUBE_VIDEO: "YouTube Video",
+ DocumentType.GITHUB_CONNECTOR: "GitHub Document",
+ DocumentType.LINEAR_CONNECTOR: "Linear Issue",
+ DocumentType.DISCORD_CONNECTOR: "Discord Message",
+ DocumentType.JIRA_CONNECTOR: "Jira Issue",
+ DocumentType.CONFLUENCE_CONNECTOR: "Confluence Page",
+ DocumentType.CLICKUP_CONNECTOR: "ClickUp Task",
+ DocumentType.GOOGLE_CALENDAR_CONNECTOR: "Google Calendar Event",
+ DocumentType.GOOGLE_GMAIL_CONNECTOR: "Gmail Message",
+ DocumentType.GOOGLE_DRIVE_FILE: "Google Drive File",
+ DocumentType.AIRTABLE_CONNECTOR: "Airtable Record",
+ DocumentType.LUMA_CONNECTOR: "Luma Event",
+ DocumentType.ELASTICSEARCH_CONNECTOR: "Elasticsearch Document",
+ DocumentType.BOOKSTACK_CONNECTOR: "BookStack Page",
+ DocumentType.CIRCLEBACK: "Circleback Meeting",
+ DocumentType.OBSIDIAN_CONNECTOR: "Obsidian Note",
+ DocumentType.DROPBOX_FILE: "Dropbox File",
+ DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Google Drive File",
+ DocumentType.COMPOSIO_GMAIL_CONNECTOR: "Gmail Message",
+ DocumentType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Google Calendar Event",
+ DocumentType.LOCAL_FOLDER_FILE: "File",
+}
+
+
+def _coerce_document_type(document_type: DocumentType | str | None) -> DocumentType | None:
+ if document_type is None:
+ return None
+ try:
+ return DocumentType(document_type)
+ except ValueError:
+ return None
+
+
+def okf_type(document_type: DocumentType | str | None) -> str:
+ """Return the OKF ``type`` string for a SurfSense document type.
+
+ Falls back to a Title-Cased version of the enum value so the required
+ ``type`` field is never empty, even for types added after this map.
+ """
+ dt = _coerce_document_type(document_type)
+ if dt is None:
+ if document_type is None:
+ return "Document"
+ return str(document_type).replace("_", " ").title() or "Document"
+ return OKF_TYPE_BY_DOCUMENT_TYPE.get(dt) or dt.value.replace("_", " ").title()
+
+
+# Per-type metadata keys that hold the canonical source URL, checked first.
+_RESOURCE_KEYS_BY_TYPE: dict[DocumentType, tuple[str, ...]] = {
+ DocumentType.GOOGLE_DRIVE_FILE: ("webViewLink", "web_view_link"),
+ DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: ("webViewLink", "web_view_link"),
+ DocumentType.GITHUB_CONNECTOR: ("html_url", "url"),
+ DocumentType.NOTION_CONNECTOR: ("url",),
+ DocumentType.LINEAR_CONNECTOR: ("url",),
+ DocumentType.JIRA_CONNECTOR: ("url",),
+ DocumentType.CONFLUENCE_CONNECTOR: ("url",),
+ DocumentType.SLACK_CONNECTOR: ("permalink", "url"),
+ DocumentType.DISCORD_CONNECTOR: ("url",),
+ DocumentType.CLICKUP_CONNECTOR: ("url",),
+ DocumentType.YOUTUBE_VIDEO: ("url", "video_url"),
+ DocumentType.LUMA_CONNECTOR: ("url",),
+ DocumentType.BOOKSTACK_CONNECTOR: ("url",),
+}
+
+# Generic URL-bearing keys checked for any type as a fallback.
+_GENERIC_RESOURCE_KEYS: tuple[str, ...] = (
+ "url",
+ "URL",
+ "source_url",
+ "sourceUrl",
+ "source",
+ "link",
+ "permalink",
+ "web_url",
+ "webViewLink",
+ "html_url",
+)
+
+
+def okf_resource(
+ document_type: DocumentType | str | None, metadata: dict[str, Any] | None
+) -> str | None:
+ """Return the canonical source URI for a document, or ``None`` if it has none."""
+ if not isinstance(metadata, dict) or not metadata:
+ return None
+
+ dt = _coerce_document_type(document_type)
+ type_keys = _RESOURCE_KEYS_BY_TYPE.get(dt, ()) if dt is not None else ()
+
+ for key in (*type_keys, *_GENERIC_RESOURCE_KEYS):
+ value = metadata.get(key)
+ if isinstance(value, str):
+ candidate = value.strip()
+ if candidate.startswith(("http://", "https://")):
+ return candidate
+ return None
From d694bcd1914c2fde8742a1c13bf2bd23fc5ef7b5 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:58:52 +0200
Subject: [PATCH 113/217] add OKF serializer
---
.../app/services/okf/serializer.py | 193 ++++++++++++++++++
1 file changed, 193 insertions(+)
create mode 100644 surfsense_backend/app/services/okf/serializer.py
diff --git a/surfsense_backend/app/services/okf/serializer.py b/surfsense_backend/app/services/okf/serializer.py
new file mode 100644
index 000000000..c866badf9
--- /dev/null
+++ b/surfsense_backend/app/services/okf/serializer.py
@@ -0,0 +1,193 @@
+"""Serialize SurfSense knowledge into Open Knowledge Format (OKF v0.1).
+
+Pure functions with no HTTP / MCP / framework dependencies: given a
+:class:`~app.db.Document` (and, for listings, its neighbours) they return
+OKF-conformant markdown. Every consumer (ZIP export, REST, MCP, agents) calls
+these rather than re-implementing frontmatter.
+
+Spec: https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+import yaml
+
+from app.db import Document
+from app.services.okf.type_mapping import okf_resource, okf_type
+
+# Reserved OKF filenames; never used for concept documents.
+INDEX_FILENAME = "index.md"
+LOG_FILENAME = "log.md"
+
+_FRONTMATTER_DELIMITER = "---"
+
+
+def _tags_from_metadata(metadata: dict[str, Any] | None) -> list[str] | None:
+ if not isinstance(metadata, dict):
+ return None
+ raw = metadata.get("tags")
+ if isinstance(raw, list):
+ tags = [str(t).strip() for t in raw if str(t).strip()]
+ return tags or None
+ return None
+
+
+def _timestamp(document: Document) -> str | None:
+ when = document.updated_at or document.created_at
+ if when is None:
+ return None
+ # ISO 8601, matching Google's sample bundles (e.g. 2026-05-28T22:49:59+00:00).
+ return when.isoformat()
+
+
+def build_frontmatter(document: Document) -> dict[str, Any]:
+ """Build the ordered OKF frontmatter mapping for a document.
+
+ Only ``type`` is required; recommended keys are included only when we have a
+ value. Insertion order is preserved in the emitted YAML.
+ """
+ metadata = document.document_metadata if isinstance(document.document_metadata, dict) else {}
+
+ frontmatter: dict[str, Any] = {"type": okf_type(document.document_type)}
+
+ resource = okf_resource(document.document_type, metadata)
+ if resource:
+ frontmatter["resource"] = resource
+
+ title = (document.title or "").strip()
+ if title:
+ frontmatter["title"] = title
+
+ description = metadata.get("description")
+ if isinstance(description, str) and description.strip():
+ frontmatter["description"] = description.strip()
+
+ tags = _tags_from_metadata(metadata)
+ if tags:
+ frontmatter["tags"] = tags
+
+ timestamp = _timestamp(document)
+ if timestamp:
+ frontmatter["timestamp"] = timestamp
+
+ return frontmatter
+
+
+def render_frontmatter(frontmatter: dict[str, Any]) -> str:
+ """Render a frontmatter mapping as a YAML block delimited by ``---``."""
+ body = yaml.safe_dump(
+ frontmatter,
+ sort_keys=False,
+ allow_unicode=True,
+ default_flow_style=False,
+ )
+ return f"{_FRONTMATTER_DELIMITER}\n{body}{_FRONTMATTER_DELIMITER}\n"
+
+
+def document_to_concept(document: Document, *, body: str) -> str:
+ """Serialize a document as an OKF concept: frontmatter + ``body``.
+
+ ``body`` is caller-resolved markdown, keeping this a pure formatting step.
+ """
+ frontmatter = render_frontmatter(build_frontmatter(document))
+ body_text = (body or "").strip("\n")
+ return f"{frontmatter}\n{body_text}\n"
+
+
+@dataclass(frozen=True)
+class ConceptRef:
+ """One concept entry for an ``index.md`` listing."""
+
+ title: str
+ filename: str # relative to the directory, e.g. "orders.md"
+ type: str # OKF type, used as the grouping heading
+ description: str | None = None
+
+
+@dataclass(frozen=True)
+class SubdirRef:
+ """One subdirectory entry for an ``index.md`` listing."""
+
+ name: str # directory name, e.g. "tables"
+ description: str | None = None
+
+
+@dataclass(frozen=True)
+class LogEntry:
+ """One line of an OKF ``log.md``: a concept and when it last changed."""
+
+ title: str
+ timestamp: str | None = None # ISO-8601, or None when unknown
+
+
+def folder_to_log(entries: list[LogEntry]) -> str:
+ """Build a minimal OKF ``log.md`` body for one directory.
+
+ Lists each concept newest-first with the time it last changed (from a
+ document's ``updated_at``/``created_at``); undated entries sort last. Returns
+ an empty string when there is nothing to log.
+
+ ponytail: this is a last-touched summary, not a per-field change history. A
+ fuller changelog would read ``DocumentVersion`` rows; upgrade there if
+ consumers ever need diffs rather than "what changed and when".
+ """
+ if not entries:
+ return ""
+ ordered = sorted(
+ entries,
+ key=lambda e: (e.timestamp is not None, e.timestamp or "", e.title),
+ reverse=True,
+ )
+ lines = ["# Change Log", ""]
+ for entry in ordered:
+ when = f" - {entry.timestamp}" if entry.timestamp else ""
+ lines.append(f"* {entry.title}{when}")
+ return "\n".join(lines) + "\n"
+
+
+def _index_bullet(title: str, link: str, description: str | None) -> str:
+ bullet = f"* [{title}]({link})"
+ if description:
+ # Keep index descriptions to a single line.
+ bullet += f" - {' '.join(description.split())}"
+ return bullet
+
+
+def folder_to_index(
+ *,
+ concepts: list[ConceptRef] | None = None,
+ subdirectories: list[SubdirRef] | None = None,
+) -> str:
+ """Build an OKF ``index.md`` body (no frontmatter) for one directory.
+
+ Subdirectories are listed under a ``# Subdirectories`` heading and concepts
+ are grouped under their ``type`` heading, mirroring Google's sample bundles.
+ Returns an empty string when there is nothing to list.
+ """
+ concepts = concepts or []
+ subdirectories = subdirectories or []
+ sections: list[str] = []
+
+ if subdirectories:
+ lines = ["# Subdirectories", ""]
+ for sub in sorted(subdirectories, key=lambda s: s.name.lower()):
+ lines.append(
+ _index_bullet(sub.name, f"{sub.name}/{INDEX_FILENAME}", sub.description)
+ )
+ sections.append("\n".join(lines))
+
+ by_type: dict[str, list[ConceptRef]] = {}
+ for concept in concepts:
+ by_type.setdefault(concept.type, []).append(concept)
+ for type_heading in sorted(by_type):
+ lines = [f"# {type_heading}", ""]
+ for concept in sorted(by_type[type_heading], key=lambda c: c.title.lower()):
+ lines.append(
+ _index_bullet(concept.title, concept.filename, concept.description)
+ )
+ sections.append("\n".join(lines))
+
+ return ("\n\n".join(sections) + "\n") if sections else ""
From f44810088846fe1889753f87c2e9e11dc28f0aa9 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:58:52 +0200
Subject: [PATCH 114/217] add OKF conformance validator
---
.../app/services/okf/validator.py | 90 +++++++++++++++++++
1 file changed, 90 insertions(+)
create mode 100644 surfsense_backend/app/services/okf/validator.py
diff --git a/surfsense_backend/app/services/okf/validator.py b/surfsense_backend/app/services/okf/validator.py
new file mode 100644
index 000000000..4d11c4ba1
--- /dev/null
+++ b/surfsense_backend/app/services/okf/validator.py
@@ -0,0 +1,90 @@
+"""Minimal OKF v0.1 conformance checks.
+
+A bundle conforms if every non-reserved ``.md`` file has a parseable YAML
+frontmatter block whose ``type`` is a non-empty string. Reserved files
+(``index.md``, ``log.md``) are exempt from the frontmatter requirement.
+Consumers must stay permissive, so this is for producers to self-check what they
+emit, not to reject foreign bundles.
+
+Spec: https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md
+"""
+
+from __future__ import annotations
+
+import yaml
+
+from app.services.okf.serializer import INDEX_FILENAME, LOG_FILENAME
+
+_DELIMITER = "---"
+RESERVED_FILENAMES = frozenset({INDEX_FILENAME, LOG_FILENAME})
+
+# The OKF v0.1 frontmatter contract, shared by serializer and validator. Only
+# ``type`` is mandatory; the rest are recommended.
+REQUIRED_FRONTMATTER_KEYS: tuple[str, ...] = ("type",)
+RECOMMENDED_FRONTMATTER_KEYS: tuple[str, ...] = (
+ "title",
+ "description",
+ "resource",
+ "tags",
+ "timestamp",
+)
+
+
+def parse_frontmatter(text: str) -> tuple[dict | None, str | None]:
+ """Split an OKF concept into ``(frontmatter_dict, error)``.
+
+ Returns ``(dict, None)`` on success or ``(None, reason)`` if the frontmatter
+ block is missing or not a parseable YAML mapping.
+ """
+ if not text.startswith(_DELIMITER + "\n"):
+ return None, "missing opening '---' frontmatter delimiter"
+ rest = text[len(_DELIMITER) + 1 :]
+ end = rest.find("\n" + _DELIMITER)
+ if end == -1:
+ return None, "missing closing '---' frontmatter delimiter"
+ block = rest[:end]
+ try:
+ data = yaml.safe_load(block)
+ except yaml.YAMLError as exc:
+ return None, f"frontmatter is not valid YAML: {exc}"
+ if not isinstance(data, dict):
+ return None, "frontmatter is not a YAML mapping"
+ return data, None
+
+
+def validate_concept(text: str) -> list[str]:
+ """Return conformance errors for a single concept document (empty == conforms)."""
+ frontmatter, error = parse_frontmatter(text)
+ if error:
+ return [error]
+ errors: list[str] = []
+ for key in REQUIRED_FRONTMATTER_KEYS:
+ value = frontmatter.get(key)
+ if not isinstance(value, str) or not value.strip():
+ errors.append(f"frontmatter '{key}' is missing or empty")
+ return errors
+
+
+def is_conformant_concept(text: str) -> bool:
+ """True if a single concept document conforms to OKF v0.1."""
+ return not validate_concept(text)
+
+
+def validate_bundle(files: dict[str, str]) -> dict[str, list[str]]:
+ """Validate a bundle given a mapping of relative path -> file contents.
+
+ Only non-reserved ``.md`` files are checked for concept conformance. Returns
+ a mapping of path -> errors for every non-conformant file (an empty mapping
+ means the whole bundle conforms).
+ """
+ problems: dict[str, list[str]] = {}
+ for path, contents in files.items():
+ if not path.endswith(".md"):
+ continue
+ filename = path.rsplit("/", 1)[-1]
+ if filename in RESERVED_FILENAMES:
+ continue
+ errors = validate_concept(contents)
+ if errors:
+ problems[path] = errors
+ return problems
From 3401b0ae0ea8d37422d65956ab2525f18d924939 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:58:52 +0200
Subject: [PATCH 115/217] add OKF package api
---
.../app/services/okf/__init__.py | 56 +++++++++++++++++++
1 file changed, 56 insertions(+)
create mode 100644 surfsense_backend/app/services/okf/__init__.py
diff --git a/surfsense_backend/app/services/okf/__init__.py b/surfsense_backend/app/services/okf/__init__.py
new file mode 100644
index 000000000..1bcb1ccb1
--- /dev/null
+++ b/surfsense_backend/app/services/okf/__init__.py
@@ -0,0 +1,56 @@
+"""Open Knowledge Format (OKF v0.1) serialization for the SurfSense KB.
+
+Single source of truth for turning documents and folders into OKF concepts,
+``index.md`` listings and ``log.md`` logs. Pure KB-layer functions; every
+consumer (ZIP export, REST, MCP, agents) calls in.
+
+The OKF-native model: ``Document`` rows are canonical, and frontmatter is
+*derived* from their columns on read (never stored), so rows are conformant by
+construction. Chunks and embeddings are a *derived*, rebuildable search
+projection - never a source of truth.
+
+Spec: https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md
+"""
+
+from app.services.okf.serializer import (
+ INDEX_FILENAME,
+ LOG_FILENAME,
+ ConceptRef,
+ LogEntry,
+ SubdirRef,
+ build_frontmatter,
+ document_to_concept,
+ folder_to_index,
+ folder_to_log,
+ render_frontmatter,
+)
+from app.services.okf.type_mapping import okf_resource, okf_type
+from app.services.okf.validator import (
+ RECOMMENDED_FRONTMATTER_KEYS,
+ REQUIRED_FRONTMATTER_KEYS,
+ is_conformant_concept,
+ parse_frontmatter,
+ validate_bundle,
+ validate_concept,
+)
+
+__all__ = [
+ "INDEX_FILENAME",
+ "LOG_FILENAME",
+ "ConceptRef",
+ "LogEntry",
+ "SubdirRef",
+ "build_frontmatter",
+ "document_to_concept",
+ "folder_to_index",
+ "folder_to_log",
+ "render_frontmatter",
+ "okf_resource",
+ "okf_type",
+ "RECOMMENDED_FRONTMATTER_KEYS",
+ "REQUIRED_FRONTMATTER_KEYS",
+ "is_conformant_concept",
+ "parse_frontmatter",
+ "validate_bundle",
+ "validate_concept",
+]
From 6a72051e063b9136fd7001767d1a3e74982e84d3 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:58:52 +0200
Subject: [PATCH 116/217] content-negotiate OKF on GET document
---
.../app/routes/documents_routes.py | 23 +++++++++++++++----
1 file changed, 19 insertions(+), 4 deletions(-)
diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py
index a5d31f07c..574bf78b5 100644
--- a/surfsense_backend/app/routes/documents_routes.py
+++ b/surfsense_backend/app/routes/documents_routes.py
@@ -1,7 +1,8 @@
# Force asyncio to use standard event loop before unstructured imports
import asyncio
-from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile
+from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile
+from fastapi.responses import PlainTextResponse
from pydantic import BaseModel as PydanticBaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
@@ -34,6 +35,8 @@ from app.schemas import (
FolderRead,
PaginatedResponse,
)
+from app.services.export_service import resolve_document_markdown
+from app.services.okf import document_to_concept
from app.services.task_dispatcher import TaskDispatcher, get_task_dispatcher
from app.users import get_auth_context
from app.utils.rbac import check_permission
@@ -1255,12 +1258,14 @@ async def get_document_chunks_paginated(
@router.get("/documents/{document_id}", response_model=DocumentRead)
async def read_document(
document_id: int,
+ request: Request,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
- """
- Get a specific document by ID.
- Requires DOCUMENTS_READ permission for the workspace.
+ """Get a document by ID.
+
+ ``Accept: text/markdown`` returns the OKF concept; otherwise the JSON record.
+ Requires DOCUMENTS_READ permission.
"""
try:
result = await session.execute(
@@ -1282,6 +1287,16 @@ async def read_document(
"You don't have permission to read documents in this workspace",
)
+ # ponytail: substring match, not RFC 7231 q-values (OKF is the only non-JSON view).
+ if "text/markdown" in request.headers.get("accept", ""):
+ body = (
+ await resolve_document_markdown(session, document)
+ or document.content
+ or ""
+ )
+ concept = document_to_concept(document, body=body)
+ return PlainTextResponse(concept, media_type="text/markdown")
+
raw_content = document.content or ""
return DocumentRead(
id=document.id,
From 3cf315880d73aaf6361aeda982862c4c39c35313 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:58:52 +0200
Subject: [PATCH 117/217] export full OKF bundle with index and log
---
.../app/services/export_service.py | 146 +++++++++++++++++-
1 file changed, 141 insertions(+), 5 deletions(-)
diff --git a/surfsense_backend/app/services/export_service.py b/surfsense_backend/app/services/export_service.py
index 2d1a8c2cf..e718c25d1 100644
--- a/surfsense_backend/app/services/export_service.py
+++ b/surfsense_backend/app/services/export_service.py
@@ -12,9 +12,26 @@ from sqlalchemy.future import select
from app.db import Chunk, Document, Folder
from app.services.folder_service import get_folder_subtree_ids
+from app.services.okf import (
+ INDEX_FILENAME,
+ LOG_FILENAME,
+ ConceptRef,
+ LogEntry,
+ SubdirRef,
+ document_to_concept,
+ folder_to_index,
+ folder_to_log,
+ okf_type,
+)
logger = logging.getLogger(__name__)
+# Root index.md declares the targeted OKF version in frontmatter - the one place
+# the spec permits frontmatter in an index file.
+_ROOT_INDEX_FRONTMATTER = '---\nokf_version: "0.1"\n---\n\n'
+
+_RESERVED_STEMS = {"index", "log"}
+
def _sanitize_filename(title: str) -> str:
safe = "".join(c if c.isalnum() or c in " -_." else "_" for c in title).strip()
@@ -43,7 +60,7 @@ def _build_folder_path_map(folders: list[Folder]) -> dict[int, str]:
return cache
-async def _get_document_markdown(
+async def resolve_document_markdown(
session: AsyncSession, document: Document
) -> str | None:
"""Resolve markdown content using the 3-tier fallback:
@@ -71,6 +88,67 @@ async def _get_document_markdown(
return None
+def _build_index_files(
+ dir_concepts: dict[str, list[ConceptRef]],
+) -> list[tuple[str, str]]:
+ """Build ``index.md`` files for every directory (and ancestor) with content.
+
+ Produces OKF progressive-disclosure listings: each directory lists its
+ concepts (grouped by type) and its immediate subdirectories. The bundle-root
+ index also declares ``okf_version``.
+ """
+ all_dirs: set[str] = {""}
+ for dir_path in dir_concepts:
+ all_dirs.add(dir_path)
+ parts = dir_path.split("/") if dir_path else []
+ for i in range(1, len(parts)):
+ all_dirs.add("/".join(parts[:i]))
+
+ children_by_dir: dict[str, list[str]] = {}
+ for dir_path in all_dirs:
+ if not dir_path:
+ continue
+ parent = dir_path.rsplit("/", 1)[0] if "/" in dir_path else ""
+ children_by_dir.setdefault(parent, []).append(dir_path)
+
+ index_files: list[tuple[str, str]] = []
+ for dir_path in all_dirs:
+ subdirs = [
+ SubdirRef(name=child.rsplit("/", 1)[-1])
+ for child in children_by_dir.get(dir_path, [])
+ ]
+ body = folder_to_index(
+ concepts=dir_concepts.get(dir_path, []),
+ subdirectories=subdirs,
+ )
+ if not body:
+ continue
+ if dir_path:
+ index_files.append((f"{dir_path}/{INDEX_FILENAME}", body))
+ else:
+ index_files.append((INDEX_FILENAME, _ROOT_INDEX_FRONTMATTER + body))
+
+ return index_files
+
+
+def _build_log_files(
+ dir_logs: dict[str, list[LogEntry]],
+) -> list[tuple[str, str]]:
+ """Build ``log.md`` files for every directory that holds concepts.
+
+ Unlike ``index.md``, logs are only synthesized where documents actually live
+ (no ancestor synthesis): an empty intermediate directory has nothing to log.
+ """
+ log_files: list[tuple[str, str]] = []
+ for dir_path, entries in dir_logs.items():
+ body = folder_to_log(entries)
+ if not body:
+ continue
+ path = f"{dir_path}/{LOG_FILENAME}" if dir_path else LOG_FILENAME
+ log_files.append((path, body))
+ return log_files
+
+
@dataclass
class ExportResult:
zip_path: str
@@ -120,6 +198,10 @@ async def build_export_zip(
used_paths: dict[str, int] = {}
skipped_docs: list[str] = []
is_first_batch = True
+ # dir path -> concepts it holds, accumulated across batches for index.md.
+ dir_concepts: dict[str, list[ConceptRef]] = {}
+ # dir path -> log entries it holds, accumulated across batches for log.md.
+ dir_logs: dict[str, list[LogEntry]] = {}
try:
offset = 0
@@ -143,7 +225,7 @@ async def build_export_zip(
skipped_docs.append(doc.title or "Untitled")
continue
- markdown = await _get_document_markdown(session, doc)
+ markdown = await resolve_document_markdown(session, doc)
if not markdown or not markdown.strip():
continue
@@ -153,6 +235,9 @@ async def build_export_zip(
dir_path = ""
base_name = _sanitize_filename(doc.title or "Untitled")
+ # Never collide with reserved OKF filenames (index.md, log.md).
+ if base_name.lower() in _RESERVED_STEMS:
+ base_name = f"{base_name}_"
file_path = (
f"{dir_path}/{base_name}.md" if dir_path else f"{base_name}.md"
)
@@ -160,14 +245,41 @@ async def build_export_zip(
if file_path in used_paths:
used_paths[file_path] += 1
suffix = used_paths[file_path]
+ base_name = f"{base_name}_{suffix}"
file_path = (
- f"{dir_path}/{base_name}_{suffix}.md"
+ f"{dir_path}/{base_name}.md"
if dir_path
- else f"{base_name}_{suffix}.md"
+ else f"{base_name}.md"
)
used_paths[file_path] = used_paths.get(file_path, 0) + 1
- entries.append((file_path, markdown))
+ concept = document_to_concept(doc, body=markdown)
+ entries.append((file_path, concept))
+
+ metadata = (
+ doc.document_metadata
+ if isinstance(doc.document_metadata, dict)
+ else {}
+ )
+ description = metadata.get("description")
+ dir_concepts.setdefault(dir_path, []).append(
+ ConceptRef(
+ title=doc.title or "Untitled",
+ filename=f"{base_name}.md",
+ type=okf_type(doc.document_type),
+ description=description
+ if isinstance(description, str) and description.strip()
+ else None,
+ )
+ )
+
+ changed_at = doc.updated_at or doc.created_at
+ dir_logs.setdefault(dir_path, []).append(
+ LogEntry(
+ title=doc.title or "Untitled",
+ timestamp=changed_at.isoformat() if changed_at else None,
+ )
+ )
if entries:
mode = "w" if is_first_batch else "a"
@@ -183,6 +295,30 @@ async def build_export_zip(
offset += batch_size
+ index_files = _build_index_files(dir_concepts)
+ if index_files:
+ mode = "w" if is_first_batch else "a"
+
+ def _write_indexes(m: str = mode, e: list = index_files) -> None:
+ with zipfile.ZipFile(tmp_path, m, zipfile.ZIP_DEFLATED) as zf:
+ for path, content in e:
+ zf.writestr(path, content)
+
+ await asyncio.to_thread(_write_indexes)
+ is_first_batch = False
+
+ log_files = _build_log_files(dir_logs)
+ if log_files:
+ mode = "w" if is_first_batch else "a"
+
+ def _write_logs(m: str = mode, e: list = log_files) -> None:
+ with zipfile.ZipFile(tmp_path, m, zipfile.ZIP_DEFLATED) as zf:
+ for path, content in e:
+ zf.writestr(path, content)
+
+ await asyncio.to_thread(_write_logs)
+ is_first_batch = False
+
export_name = "knowledge-base"
if folder_id is not None and folder_id in folder_path_map:
export_name = _sanitize_filename(folder_path_map[folder_id].split("/")[0])
From edf38edc3fa975c233dac45259265a4a807b386d Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:58:52 +0200
Subject: [PATCH 118/217] support per-request headers
---
surfsense_mcp/mcp_server/core/client.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/surfsense_mcp/mcp_server/core/client.py b/surfsense_mcp/mcp_server/core/client.py
index 9ae446ae2..d65b47b7e 100644
--- a/surfsense_mcp/mcp_server/core/client.py
+++ b/surfsense_mcp/mcp_server/core/client.py
@@ -61,13 +61,17 @@ class SurfSenseClient:
json: Any | None = None,
data: dict[str, Any] | None = None,
files: Any | None = None,
+ headers: dict[str, str] | None = None,
) -> Any:
- """Send a request and return the parsed body, or raise ``ToolError``."""
+ """Send a request and return the parsed body, or raise ``ToolError``.
+
+ ``headers`` overrides the client defaults for this call.
+ """
# Omit unset query params: sending them empty makes the API parse ""
# as a value (e.g. int("") on folder_id) and fail.
if params is not None:
params = {key: value for key, value in params.items() if value is not None}
- headers = self._auth_headers()
+ headers = {**self._auth_headers(), **(headers or {})}
try:
response = await self._http.request(
method,
From a01b292886c5654595b80008b219bacd2faaaa3e Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:58:53 +0200
Subject: [PATCH 119/217] request OKF concept via content negotiation
---
.../features/knowledge_base/search_tools.py | 23 ++++++++-----------
1 file changed, 10 insertions(+), 13 deletions(-)
diff --git a/surfsense_mcp/mcp_server/features/knowledge_base/search_tools.py b/surfsense_mcp/mcp_server/features/knowledge_base/search_tools.py
index c0f7f83a9..dc085e00f 100644
--- a/surfsense_mcp/mcp_server/features/knowledge_base/search_tools.py
+++ b/surfsense_mcp/mcp_server/features/knowledge_base/search_tools.py
@@ -123,11 +123,19 @@ def register(mcp: FastMCP, client: SurfSenseClient, context: WorkspaceContext) -
Use this after surfsense_search_knowledge_base or
surfsense_list_documents to open a specific document — search results
only include the matching passages, this returns the whole text.
+ The markdown form is an Open Knowledge Format (OKF) concept: a YAML
+ frontmatter block (type, title, tags, resource, timestamp) followed by
+ the document body.
"""
- document = await client.request("GET", f"/documents/{document_id}")
if response_format == "json":
+ document = await client.request("GET", f"/documents/{document_id}")
return clip(to_json(document))
- return _render_document(document)
+ concept = await client.request(
+ "GET",
+ f"/documents/{document_id}",
+ headers={"Accept": "text/markdown"},
+ )
+ return clip(concept if isinstance(concept, str) else str(concept))
def _join(values: list[str] | None) -> str | None:
@@ -169,14 +177,3 @@ def _render_document_list(result: dict | None) -> str:
+ (" · more available_" if has_more else "_")
)
return "\n".join(lines)
-
-
-def _render_document(document: dict) -> str:
- content = clip(document.get("content", "") or "(empty)")
- return (
- f"# {document.get('title', 'Untitled')} (id {document.get('id')})\n"
- f"- type: {document.get('document_type')}\n"
- f"- workspace: {document.get('workspace_id')}\n"
- f"- updated: {document.get('updated_at')}\n\n"
- f"{content}"
- )
From 7c53909b495d3b4c0f6e30de3734376f33430a52 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:59:00 +0200
Subject: [PATCH 120/217] test: OKF serializer self-checks
---
.../unit/services/okf/test_serializer.py | 130 ++++++++++++++++++
1 file changed, 130 insertions(+)
create mode 100644 surfsense_backend/tests/unit/services/okf/test_serializer.py
diff --git a/surfsense_backend/tests/unit/services/okf/test_serializer.py b/surfsense_backend/tests/unit/services/okf/test_serializer.py
new file mode 100644
index 000000000..157ed44c8
--- /dev/null
+++ b/surfsense_backend/tests/unit/services/okf/test_serializer.py
@@ -0,0 +1,130 @@
+"""OKF serializer/validator self-checks: emitted concepts stay conformant and the
+frontmatter fields consumers rely on (type/title/timestamp) round-trip.
+"""
+
+from datetime import UTC, datetime
+
+from app.db import Document, DocumentType
+from app.services.okf import (
+ ConceptRef,
+ LogEntry,
+ SubdirRef,
+ document_to_concept,
+ folder_to_index,
+ folder_to_log,
+ is_conformant_concept,
+ parse_frontmatter,
+ validate_concept,
+)
+
+
+def _make_document() -> Document:
+ return Document(
+ title="Weekly Sync Notes",
+ document_type=DocumentType.NOTE,
+ document_metadata={"tags": ["team", "meeting"], "url": "https://example.com/n"},
+ updated_at=datetime(2026, 5, 28, 22, 49, 59, tzinfo=UTC),
+ )
+
+
+def test_concept_is_conformant_and_roundtrips() -> None:
+ concept = document_to_concept(_make_document(), body="# Agenda\n\nShip OKF.")
+
+ assert is_conformant_concept(concept)
+
+ frontmatter, error = parse_frontmatter(concept)
+ assert error is None
+ assert frontmatter["type"] == "Note"
+ assert frontmatter["title"] == "Weekly Sync Notes"
+ assert frontmatter["tags"] == ["team", "meeting"]
+ assert frontmatter["resource"] == "https://example.com/n"
+ # timestamp must survive as an ISO-8601 string, not a parsed datetime.
+ assert frontmatter["timestamp"] == "2026-05-28T22:49:59+00:00"
+ assert "# Agenda" in concept
+
+
+def test_type_is_always_present_even_without_metadata() -> None:
+ doc = Document(title="Raw", document_type=DocumentType.CRAWLED_URL)
+ concept = document_to_concept(doc, body="body")
+ frontmatter, error = parse_frontmatter(concept)
+ assert error is None
+ assert frontmatter["type"] == "Web Page"
+ # No source URL / tags available -> those recommended keys are omitted.
+ assert "resource" not in frontmatter
+ assert "tags" not in frontmatter
+
+
+def test_validator_rejects_non_conformant_documents() -> None:
+ assert validate_concept("no frontmatter here")
+ assert validate_concept("---\ntitle: Missing type\n---\nbody")
+
+
+def test_folder_index_groups_by_type_and_lists_subdirs() -> None:
+ index = folder_to_index(
+ concepts=[
+ ConceptRef(title="Orders", filename="orders.md", type="Note", description="x"),
+ ],
+ subdirectories=[SubdirRef(name="tables", description="Table docs")],
+ )
+ assert "# Subdirectories" in index
+ assert "* [tables](tables/index.md) - Table docs" in index
+ assert "# Note" in index
+ assert "* [Orders](orders.md) - x" in index
+
+
+def test_folder_log_lists_concepts_newest_first() -> None:
+ log = folder_to_log(
+ [
+ LogEntry(title="Older", timestamp="2026-01-01T00:00:00+00:00"),
+ LogEntry(title="Newer", timestamp="2026-06-01T00:00:00+00:00"),
+ LogEntry(title="Undated", timestamp=None),
+ ]
+ )
+ assert "# Change Log" in log
+ # Newest dated entry precedes the older one; undated sorts last.
+ assert log.index("Newer") < log.index("Older") < log.index("Undated")
+ assert "* Newer - 2026-06-01T00:00:00+00:00" in log
+
+
+def test_folder_log_is_empty_when_no_entries() -> None:
+ assert folder_to_log([]) == ""
+
+
+def test_export_log_files_synthesized_only_where_docs_live() -> None:
+ from app.services.export_service import _build_log_files
+
+ files = dict(
+ _build_log_files(
+ {
+ "": [LogEntry(title="Root Doc", timestamp="2026-05-01T00:00:00+00:00")],
+ "Research/AI": [LogEntry(title="Nested", timestamp=None)],
+ }
+ )
+ )
+ assert "# Change Log" in files["log.md"]
+ assert "Root Doc" in files["log.md"]
+ assert "Nested" in files["Research/AI/log.md"]
+ # No empty intermediate log: "Research" holds no concepts of its own.
+ assert "Research/log.md" not in files
+
+
+def test_export_index_files_include_root_version_and_ancestors() -> None:
+ from app.services.export_service import _build_index_files
+
+ # A concept nested two levels deep, with no direct docs in the middle dir.
+ files = dict(
+ _build_index_files(
+ {
+ "Research/AI": [
+ ConceptRef(title="Note", filename="note.md", type="Note")
+ ]
+ }
+ )
+ )
+
+ # Root, the empty intermediate dir, and the leaf all get an index.md so the
+ # hierarchy is fully navigable.
+ assert files["index.md"].startswith('---\nokf_version: "0.1"\n---')
+ assert "* [Research](Research/index.md)" in files["index.md"]
+ assert "* [AI](AI/index.md)" in files["Research/index.md"]
+ assert "* [Note](note.md)" in files["Research/AI/index.md"]
From 863d9a2248a7e1089b710a99a1914d1c8e8984b9 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:59:00 +0200
Subject: [PATCH 121/217] test: per-type OKF conformance
---
.../unit/services/okf/test_conformance.py | 51 +++++++++++++++++++
1 file changed, 51 insertions(+)
create mode 100644 surfsense_backend/tests/unit/services/okf/test_conformance.py
diff --git a/surfsense_backend/tests/unit/services/okf/test_conformance.py b/surfsense_backend/tests/unit/services/okf/test_conformance.py
new file mode 100644
index 000000000..da1988224
--- /dev/null
+++ b/surfsense_backend/tests/unit/services/okf/test_conformance.py
@@ -0,0 +1,51 @@
+"""Every ``DocumentType`` must serialize to a concept a permissive OKF consumer
+can read: parseable frontmatter with a non-empty ``type``. Covers all types plus
+missing metadata, empty body, and non-ASCII titles.
+"""
+
+from datetime import UTC, datetime
+
+import pytest
+
+from app.db import Document, DocumentType
+from app.services.okf import document_to_concept, is_conformant_concept
+from app.services.okf.validator import (
+ RECOMMENDED_FRONTMATTER_KEYS,
+ REQUIRED_FRONTMATTER_KEYS,
+)
+
+
+@pytest.mark.parametrize("document_type", list(DocumentType))
+def test_every_document_type_serializes_to_conformant_concept(
+ document_type: DocumentType,
+) -> None:
+ doc = Document(
+ title="Sample",
+ document_type=document_type,
+ document_metadata={"url": "https://example.com/x"},
+ updated_at=datetime(2026, 5, 28, tzinfo=UTC),
+ )
+ assert is_conformant_concept(document_to_concept(doc, body="body"))
+
+
+def test_conformant_without_metadata_or_body() -> None:
+ doc = Document(title="Bare", document_type=DocumentType.NOTE)
+ assert is_conformant_concept(document_to_concept(doc, body=""))
+
+
+def test_conformant_with_non_ascii_title() -> None:
+ doc = Document(title="日本語ノート", document_type=DocumentType.NOTE)
+ concept = document_to_concept(doc, body="本文")
+ assert is_conformant_concept(concept)
+ assert "日本語ノート" in concept
+
+
+def test_contract_marks_only_type_as_required() -> None:
+ assert REQUIRED_FRONTMATTER_KEYS == ("type",)
+ assert set(RECOMMENDED_FRONTMATTER_KEYS) == {
+ "title",
+ "description",
+ "resource",
+ "tags",
+ "timestamp",
+ }
From 5d2f9f1ca84a966b39830a5c043223ec878d69aa Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:59:01 +0200
Subject: [PATCH 122/217] test: connector document is OKF concept
---
.../tests/unit/services/okf/test_ingestion.py | 31 +++++++++++++++++++
1 file changed, 31 insertions(+)
create mode 100644 surfsense_backend/tests/unit/services/okf/test_ingestion.py
diff --git a/surfsense_backend/tests/unit/services/okf/test_ingestion.py b/surfsense_backend/tests/unit/services/okf/test_ingestion.py
new file mode 100644
index 000000000..e7c49bc33
--- /dev/null
+++ b/surfsense_backend/tests/unit/services/okf/test_ingestion.py
@@ -0,0 +1,31 @@
+"""A ConnectorDocument (the indexing write door) must serialize to a valid OKF
+concept. Per-``DocumentType`` conformance lives in ``test_conformance.py``.
+"""
+
+from app.db import Document, DocumentType
+from app.indexing_pipeline.connector_document import ConnectorDocument
+from app.services.okf import document_to_concept, is_conformant_concept
+
+
+def _document_from(connector_doc: ConnectorDocument) -> Document:
+ """Mirror how prepare_for_indexing builds a Document from a ConnectorDocument."""
+ return Document(
+ title=connector_doc.title,
+ document_type=connector_doc.document_type,
+ source_markdown=connector_doc.source_markdown,
+ document_metadata=connector_doc.metadata,
+ )
+
+
+def test_minimal_connector_document_yields_conformant_concept() -> None:
+ connector_doc = ConnectorDocument(
+ title="Bare",
+ source_markdown="just a body",
+ unique_id="u1",
+ document_type=DocumentType.FILE,
+ workspace_id=1,
+ created_by_id="user-1",
+ )
+ doc = _document_from(connector_doc)
+ concept = document_to_concept(doc, body=doc.source_markdown)
+ assert is_conformant_concept(concept)
From bdc03670a57b665d4583cf305f15e3be365d0b76 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:59:01 +0200
Subject: [PATCH 123/217] test: OKF bundle conformance audit
---
.../tests/unit/services/okf/test_audit.py | 44 +++++++++++++++++++
1 file changed, 44 insertions(+)
create mode 100644 surfsense_backend/tests/unit/services/okf/test_audit.py
diff --git a/surfsense_backend/tests/unit/services/okf/test_audit.py b/surfsense_backend/tests/unit/services/okf/test_audit.py
new file mode 100644
index 000000000..f8303e237
--- /dev/null
+++ b/surfsense_backend/tests/unit/services/okf/test_audit.py
@@ -0,0 +1,44 @@
+"""A real export bundle - concepts plus reserved ``index.md``/``log.md`` - must
+pass the same ``validate_bundle`` a consumer would run.
+"""
+
+from datetime import UTC, datetime
+
+from app.db import Document, DocumentType
+from app.services.okf import (
+ LogEntry,
+ document_to_concept,
+ folder_to_index,
+ folder_to_log,
+ validate_bundle,
+)
+
+
+def _sample_bundle() -> dict[str, str]:
+ note = Document(
+ title="Weekly Sync",
+ document_type=DocumentType.NOTE,
+ document_metadata={"tags": ["team"]},
+ updated_at=datetime(2026, 5, 28, tzinfo=UTC),
+ )
+ page = Document(title="Docs Home", document_type=DocumentType.CRAWLED_URL)
+ return {
+ "weekly-sync.md": document_to_concept(note, body="# Agenda"),
+ "docs-home.md": document_to_concept(page, body="content"),
+ # Reserved files carry no frontmatter and must be exempt from the check.
+ "index.md": folder_to_index(),
+ "log.md": folder_to_log(
+ [LogEntry(title="Weekly Sync", timestamp="2026-05-28T00:00:00+00:00")]
+ ),
+ }
+
+
+def test_real_export_bundle_is_conformant() -> None:
+ assert validate_bundle(_sample_bundle()) == {}
+
+
+def test_audit_flags_a_drifted_concept() -> None:
+ bundle = _sample_bundle()
+ bundle["broken.md"] = "no frontmatter at all"
+ problems = validate_bundle(bundle)
+ assert list(problems) == ["broken.md"]
From ea20f0042def858821be1304c4cc16ae69cbed50 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:59:01 +0200
Subject: [PATCH 124/217] test: OKF export bundle is conformant
---
.../integration/test_okf_export_bundle.py | 77 +++++++++++++++++++
1 file changed, 77 insertions(+)
create mode 100644 surfsense_backend/tests/integration/test_okf_export_bundle.py
diff --git a/surfsense_backend/tests/integration/test_okf_export_bundle.py b/surfsense_backend/tests/integration/test_okf_export_bundle.py
new file mode 100644
index 000000000..bccd3ac4a
--- /dev/null
+++ b/surfsense_backend/tests/integration/test_okf_export_bundle.py
@@ -0,0 +1,77 @@
+"""A real export must be a conformant OKF bundle end-to-end (real DB).
+
+Unit tests cover the pure serializer; this drives the whole export pipeline
+(folder-path map, batching, ZIP writing) and asserts the emitted artifact -
+concept files plus reserved ``index.md``/``log.md`` - passes ``validate_bundle``.
+"""
+
+import os
+import zipfile
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db import Document, DocumentType, Folder, User, Workspace
+from app.services.export_service import build_export_zip
+from app.services.okf import validate_bundle
+
+pytestmark = pytest.mark.integration
+
+
+async def _add_doc(
+ session: AsyncSession,
+ *,
+ workspace: Workspace,
+ user: User,
+ title: str,
+ folder_id: int | None,
+ uid: str,
+) -> Document:
+ doc = Document(
+ title=title,
+ document_type=DocumentType.NOTE,
+ document_metadata={"tags": ["team"]},
+ content="body text",
+ content_hash=uid,
+ unique_identifier_hash=uid,
+ source_markdown=f"# {title}\n\nBody.",
+ workspace_id=workspace.id,
+ created_by_id=user.id,
+ folder_id=folder_id,
+ )
+ session.add(doc)
+ await session.flush()
+ return doc
+
+
+async def test_export_bundle_is_okf_conformant(
+ db_session: AsyncSession, db_user: User, db_workspace: Workspace
+):
+ folder = Folder(name="Research", position="0", workspace_id=db_workspace.id)
+ db_session.add(folder)
+ await db_session.flush()
+
+ await _add_doc(
+ db_session, workspace=db_workspace, user=db_user,
+ title="Root Note", folder_id=None, uid="okf-export-root",
+ )
+ await _add_doc(
+ db_session, workspace=db_workspace, user=db_user,
+ title="Nested Note", folder_id=folder.id, uid="okf-export-nested",
+ )
+
+ result = await build_export_zip(db_session, db_workspace.id)
+ try:
+ with zipfile.ZipFile(result.zip_path) as zf:
+ files = {name: zf.read(name).decode("utf-8") for name in zf.namelist()}
+ finally:
+ os.unlink(result.zip_path)
+
+ # Directory structure: concepts nested by folder, plus reserved files.
+ assert "Root Note.md" in files
+ assert "Research/Nested Note.md" in files
+ assert files["index.md"].startswith('---\nokf_version: "0.1"\n---')
+ assert any(name.endswith("log.md") for name in files)
+
+ # The whole bundle conforms; reserved index/log files are exempt.
+ assert validate_bundle(files) == {}
From 826910f50d86943f1e5377dc28b2c3d805edd155 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:59:01 +0200
Subject: [PATCH 125/217] test: OKF path-identity roundtrip
---
.../integration/test_okf_path_identity.py | 116 ++++++++++++++++++
1 file changed, 116 insertions(+)
create mode 100644 surfsense_backend/tests/integration/test_okf_path_identity.py
diff --git a/surfsense_backend/tests/integration/test_okf_path_identity.py b/surfsense_backend/tests/integration/test_okf_path_identity.py
new file mode 100644
index 000000000..8c12d7844
--- /dev/null
+++ b/surfsense_backend/tests/integration/test_okf_path_identity.py
@@ -0,0 +1,116 @@
+"""A document's virtual path must resolve back to the same row: concepts are
+identified by path (``doc_to_virtual_path`` <-> ``virtual_path_to_doc``). Covers
+the hard cases - folder nesting and colliding titles.
+"""
+
+import pytest
+import pytest_asyncio
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.agents.chat.runtime.path_resolver import (
+ build_path_index,
+ doc_to_virtual_path,
+ virtual_path_to_doc,
+)
+from app.db import Document, DocumentType, Folder, User, Workspace
+
+pytestmark = pytest.mark.integration
+
+
+async def _add_document(
+ session: AsyncSession,
+ *,
+ workspace: Workspace,
+ user: User,
+ title: str,
+ folder_id: int | None,
+ unique_hash: str,
+) -> Document:
+ doc = Document(
+ title=title,
+ document_type=DocumentType.NOTE,
+ document_metadata={},
+ content="body",
+ content_hash=unique_hash,
+ unique_identifier_hash=unique_hash,
+ source_markdown="body",
+ workspace_id=workspace.id,
+ created_by_id=user.id,
+ folder_id=folder_id,
+ )
+ session.add(doc)
+ await session.flush()
+ return doc
+
+
+async def _roundtrip(
+ session: AsyncSession, workspace: Workspace, doc: Document
+) -> Document | None:
+ index = await build_path_index(session, workspace.id)
+ path = doc_to_virtual_path(
+ doc_id=doc.id, title=doc.title, folder_id=doc.folder_id, index=index
+ )
+ return await virtual_path_to_doc(
+ session, workspace_id=workspace.id, virtual_path=path
+ )
+
+
+@pytest_asyncio.fixture
+async def research_folder(
+ db_session: AsyncSession, db_workspace: Workspace
+) -> Folder:
+ folder = Folder(name="Research", position="0", workspace_id=db_workspace.id)
+ db_session.add(folder)
+ await db_session.flush()
+ return folder
+
+
+async def test_folder_nested_document_roundtrips(
+ db_session, db_user, db_workspace, research_folder
+):
+ doc = await _add_document(
+ db_session,
+ workspace=db_workspace,
+ user=db_user,
+ title="My Note",
+ folder_id=research_folder.id,
+ unique_hash="hash-nested",
+ )
+ assert await _roundtrip(db_session, db_workspace, doc) is doc
+
+
+async def test_colliding_titles_get_distinct_resolvable_paths(
+ db_session, db_user, db_workspace
+):
+ first = await _add_document(
+ db_session,
+ workspace=db_workspace,
+ user=db_user,
+ title="Hello",
+ folder_id=None,
+ unique_hash="hash-a",
+ )
+ second = await _add_document(
+ db_session,
+ workspace=db_workspace,
+ user=db_user,
+ title="Hello",
+ folder_id=None,
+ unique_hash="hash-b",
+ )
+
+ index = await build_path_index(db_session, db_workspace.id)
+ first_path = doc_to_virtual_path(
+ doc_id=first.id, title=first.title, folder_id=None, index=index
+ )
+ second_path = doc_to_virtual_path(
+ doc_id=second.id, title=second.title, folder_id=None, index=index
+ )
+ # Distinct identities: the collision is broken by a " ().xml" suffix.
+ assert first_path != second_path
+
+ # Only the disambiguated path is stable; the bare title stays ambiguous while a twin exists.
+ resolved = await virtual_path_to_doc(
+ db_session, workspace_id=db_workspace.id, virtual_path=second_path
+ )
+ assert resolved is second
From c99a8154c0ff61522061b62ced7e8f3472afa8af Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:59:01 +0200
Subject: [PATCH 126/217] test: content-negotiated OKF read over HTTP
---
.../document_upload/test_okf_read.py | 66 +++++++++++++++++++
1 file changed, 66 insertions(+)
create mode 100644 surfsense_backend/tests/integration/document_upload/test_okf_read.py
diff --git a/surfsense_backend/tests/integration/document_upload/test_okf_read.py b/surfsense_backend/tests/integration/document_upload/test_okf_read.py
new file mode 100644
index 000000000..67a11a767
--- /dev/null
+++ b/surfsense_backend/tests/integration/document_upload/test_okf_read.py
@@ -0,0 +1,66 @@
+"""GET /documents/{id} content-negotiates its representation (real HTTP, DB).
+
+Proves the live wiring the unit tests can't: ``Accept: text/markdown`` returns a
+conformant OKF concept, and the default request still returns the JSON record -
+both off the same endpoint, through the real FastAPI stack.
+"""
+
+from __future__ import annotations
+
+import httpx
+import pytest
+
+from app.services.okf import is_conformant_concept
+from tests.utils.helpers import poll_document_status, upload_file
+
+pytestmark = pytest.mark.integration
+
+
+async def _ready_doc_id(
+ client: httpx.AsyncClient,
+ headers: dict[str, str],
+ workspace_id: int,
+ cleanup_doc_ids: list[int],
+) -> int:
+ resp = await upload_file(client, headers, "sample.txt", workspace_id=workspace_id)
+ assert resp.status_code == 200
+ doc_ids = resp.json()["document_ids"]
+ cleanup_doc_ids.extend(doc_ids)
+ statuses = await poll_document_status(
+ client, headers, doc_ids, workspace_id=workspace_id
+ )
+ assert statuses[doc_ids[0]]["status"]["state"] == "ready"
+ return doc_ids[0]
+
+
+async def test_accept_markdown_returns_conformant_okf_concept(
+ client: httpx.AsyncClient,
+ headers: dict[str, str],
+ workspace_id: int,
+ cleanup_doc_ids: list[int],
+):
+ doc_id = await _ready_doc_id(client, headers, workspace_id, cleanup_doc_ids)
+
+ resp = await client.get(
+ f"/api/v1/documents/{doc_id}",
+ headers={**headers, "Accept": "text/markdown"},
+ )
+
+ assert resp.status_code == 200
+ assert resp.headers["content-type"].startswith("text/markdown")
+ assert is_conformant_concept(resp.text)
+
+
+async def test_default_accept_returns_json_record(
+ client: httpx.AsyncClient,
+ headers: dict[str, str],
+ workspace_id: int,
+ cleanup_doc_ids: list[int],
+):
+ doc_id = await _ready_doc_id(client, headers, workspace_id, cleanup_doc_ids)
+
+ resp = await client.get(f"/api/v1/documents/{doc_id}", headers=headers)
+
+ assert resp.status_code == 200
+ assert resp.headers["content-type"].startswith("application/json")
+ assert resp.json()["id"] == doc_id
From 9a183924842205b680c6441faff3e7024105ce6b Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:59:01 +0200
Subject: [PATCH 127/217] test: tidy concept-identity roundtrip docstring
---
.../agents/new_chat/test_path_resolver.py | 27 +++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_path_resolver.py b/surfsense_backend/tests/unit/agents/new_chat/test_path_resolver.py
index ba9dc242b..ad3b17c9c 100644
--- a/surfsense_backend/tests/unit/agents/new_chat/test_path_resolver.py
+++ b/surfsense_backend/tests/unit/agents/new_chat/test_path_resolver.py
@@ -79,6 +79,33 @@ class TestDocToVirtualPath:
assert path == f"{DOCUMENTS_ROOT}/notes/A.xml"
+class TestConceptIdentityRoundTrip:
+ """A document's virtual path must parse back to the same (folder, title) - the
+ OKF path-identity guarantee at the grammar level, no DB required."""
+
+ def test_folder_nested_document_roundtrips(self):
+ index = PathIndex(folder_paths={5: f"{DOCUMENTS_ROOT}/Research/AI"})
+ path = doc_to_virtual_path(
+ doc_id=2, title="My Note", folder_id=5, index=index
+ )
+ assert path == f"{DOCUMENTS_ROOT}/Research/AI/My Note.xml"
+ folder_parts, title = parse_documents_path(path)
+ assert folder_parts == ["Research", "AI"]
+ assert title == "My Note"
+
+ def test_colliding_title_roundtrips_to_base_title(self):
+ # Second doc with the same title gets a " ()" suffix; parsing the
+ # path must strip the disambiguator and recover the original title.
+ index = PathIndex(occupants={f"{DOCUMENTS_ROOT}/Hello.xml": 7})
+ path = doc_to_virtual_path(
+ doc_id=8, title="Hello", folder_id=None, index=index
+ )
+ assert path == f"{DOCUMENTS_ROOT}/Hello (8).xml"
+ folder_parts, title = parse_documents_path(path)
+ assert folder_parts == []
+ assert title == "Hello"
+
+
class TestParseDocumentsPath:
def test_extracts_folder_parts_and_title(self):
parts, title = parse_documents_path(f"{DOCUMENTS_ROOT}/foo/bar/baz.xml")
From 5f1cc7a6341990ac97068b966e1e2e494706a8e8 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Tue, 21 Jul 2026 19:59:01 +0200
Subject: [PATCH 128/217] test: MCP get_document forwards Accept header
---
surfsense_mcp/tests/test_get_document_okf.py | 68 ++++++++++++++++++++
1 file changed, 68 insertions(+)
create mode 100644 surfsense_mcp/tests/test_get_document_okf.py
diff --git a/surfsense_mcp/tests/test_get_document_okf.py b/surfsense_mcp/tests/test_get_document_okf.py
new file mode 100644
index 000000000..8b5cdae2f
--- /dev/null
+++ b/surfsense_mcp/tests/test_get_document_okf.py
@@ -0,0 +1,68 @@
+"""surfsense_get_document round-trips through the real tool registration.
+
+The markdown form must ask the backend for the OKF concept via content
+negotiation (``Accept: text/markdown``) and pass it through untouched; the JSON
+form must leave the default ``application/json`` Accept in place. This is the
+only coverage of the MCP-side glue that forwards the header.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import MagicMock
+
+import httpx
+from mcp.server.fastmcp import FastMCP
+
+from mcp_server.core.client import SurfSenseClient
+from mcp_server.features.knowledge_base import search_tools
+
+_CONCEPT = "---\ntype: Note\ntitle: T\n---\n\nBody."
+
+
+def _client_recording(seen: dict) -> SurfSenseClient:
+ async def handler(request: httpx.Request) -> httpx.Response:
+ seen["path"] = request.url.path
+ seen["accept"] = request.headers.get("accept")
+ if "text/markdown" in (seen["accept"] or ""):
+ return httpx.Response(
+ 200, text=_CONCEPT, headers={"content-type": "text/markdown"}
+ )
+ return httpx.Response(200, json={"id": 1, "title": "T"})
+
+ client = SurfSenseClient(
+ api_base="http://test/api/v1", timeout=5, fallback_api_key="ss_pat_x"
+ )
+ client._http = httpx.AsyncClient(
+ base_url="http://test/api/v1",
+ headers={"Accept": "application/json"},
+ transport=httpx.MockTransport(handler),
+ )
+ return client
+
+
+def _call_get_document(client: SurfSenseClient, **arguments) -> str:
+ mcp = FastMCP("test")
+ search_tools.register(mcp, client, MagicMock())
+ blocks = asyncio.run(mcp.call_tool("surfsense_get_document", arguments))
+ return "".join(block.text for block in blocks)
+
+
+def test_markdown_requests_okf_concept_and_passes_it_through():
+ seen: dict = {}
+ text = _call_get_document(_client_recording(seen), document_id=1)
+
+ assert seen["path"] == "/api/v1/documents/1"
+ assert "text/markdown" in seen["accept"]
+ assert text == _CONCEPT
+
+
+def test_json_keeps_default_accept():
+ seen: dict = {}
+ text = _call_get_document(
+ _client_recording(seen), document_id=1, response_format="json"
+ )
+
+ assert seen["path"] == "/api/v1/documents/1"
+ assert seen["accept"] == "application/json"
+ assert '"id": 1' in text
From 19a9a84b4524b307ef24d36cdf91d7e6014845ad Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 22 Jul 2026 00:37:52 +0530
Subject: [PATCH 129/217] fix: increase maximum workspaces per user limit from
100 to 400
---
surfsense_backend/app/config/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py
index 2f909c31f..cd6b9208c 100644
--- a/surfsense_backend/app/config/__init__.py
+++ b/surfsense_backend/app/config/__init__.py
@@ -857,7 +857,7 @@ class Config:
REGISTRATION_ENABLED = os.getenv("REGISTRATION_ENABLED", "TRUE").upper() == "TRUE"
# Max workspaces a user may own. The frontend reads this through the
# workspace limits route; do not duplicate this value client-side.
- MAX_WORKSPACES_PER_USER = int(os.getenv("MAX_WORKSPACES_PER_USER", "100"))
+ MAX_WORKSPACES_PER_USER = int(os.getenv("MAX_WORKSPACES_PER_USER", "400"))
# Google OAuth
GOOGLE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
From 7ecf01f308044995085eba519ef7c7314789979b Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 22 Jul 2026 01:38:53 +0530
Subject: [PATCH 130/217] refactor: remove onPaste handler from
InlineMentionEditor for cleaner code
---
.../components/assistant-ui/inline-mention-editor.tsx | 8 +-------
1 file changed, 1 insertion(+), 7 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
index ee2d1c873..f1b8554e7 100644
--- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
+++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx
@@ -733,15 +733,9 @@ export const InlineMentionEditor = forwardRef ({
placeholder,
- onPaste: (e: React.ClipboardEvent) => {
- e.preventDefault();
- const text = e.clipboardData.getData("text/plain");
- const tf = editor.tf as { insertText: (value: string) => void };
- tf.insertText(text);
- },
onKeyDown: handleKeyDown,
}),
- [editor, handleKeyDown, placeholder]
+ [handleKeyDown, placeholder]
);
const mentionEditorContextValue = useMemo(
From df98144a243c570d310e0573cd8aaf208010b461 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 22 Jul 2026 01:55:27 +0530
Subject: [PATCH 131/217] refactor: remove downloading functionality from
EditorPanelContent for cleaner code
---
.../components/editor-panel/editor-panel.tsx | 59 -------------------
1 file changed, 59 deletions(-)
diff --git a/surfsense_web/components/editor-panel/editor-panel.tsx b/surfsense_web/components/editor-panel/editor-panel.tsx
index 4f2f598d8..54b964a41 100644
--- a/surfsense_web/components/editor-panel/editor-panel.tsx
+++ b/surfsense_web/components/editor-panel/editor-panel.tsx
@@ -4,7 +4,6 @@ import { useAtomValue, useSetAtom } from "jotai";
import {
Check,
Copy,
- Download,
FileQuestionMark,
FileText,
Pencil,
@@ -163,7 +162,6 @@ export function EditorPanelContent({
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
- const [downloading, setDownloading] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [memoryLimits, setMemoryLimits] = useState(null);
@@ -514,62 +512,6 @@ export function EditorPanelContent({
setIsEditing(false);
}, [editorDoc?.source_markdown]);
- const handleDownloadMarkdown = useCallback(async () => {
- if (!workspaceId || !documentId) return;
- setDownloading(true);
- try {
- const response = await authenticatedFetch(
- buildBackendUrl(
- `/api/v1/workspaces/${workspaceId}/documents/${documentId}/download-markdown`
- ),
- { method: "GET" }
- );
- if (!response.ok) throw new Error("Download failed");
- const blob = await response.blob();
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- const disposition = response.headers.get("content-disposition");
- const match = disposition?.match(/filename="(.+)"/);
- a.download = match?.[1] ?? `${editorDoc?.title || "document"}.md`;
- document.body.appendChild(a);
- a.click();
- a.remove();
- URL.revokeObjectURL(url);
- toast.success("Download started");
- } catch {
- toast.error("Failed to download document");
- } finally {
- setDownloading(false);
- }
- }, [documentId, editorDoc?.title, workspaceId]);
-
- const largeDocAlert = viewerMode === "monaco" && !isLocalFileMode && editorDoc && (
-
-
-
-
- This document is too large for the editor (
- {formatBytes(editorDoc.content_size_bytes ?? 0)}, {docLineCount.toLocaleString()} lines,{" "}
- {editorDoc.chunk_count ?? 0} chunks). Showing raw markdown below.
-
-
-
-
- Download .md
-
- {downloading && }
-
-
-
- );
-
return (
<>
{showDesktopHeader ? (
@@ -807,7 +749,6 @@ export function EditorPanelContent({
) : viewerMode === "monaco" && !isLocalFileMode ? (
// Large doc — raw markdown in Monaco. Rich renderers are intentionally skipped.
- {largeDocAlert}
Date: Wed, 22 Jul 2026 12:55:36 +0530
Subject: [PATCH 132/217] refactor: remove unused thread metadata migration and
clean up chat thread queries
---
.../175_publish_thread_metadata_to_zero.py | 23 ----
surfsense_backend/app/zero_publication.py | 6 +-
surfsense_web/hooks/use-resolved-tabs.test.ts | 20 ++--
surfsense_web/hooks/use-resolved-tabs.ts | 100 ++++++++++++------
surfsense_web/zero/queries/chat.ts | 10 --
surfsense_web/zero/queries/documents.ts | 3 -
surfsense_web/zero/queries/index.ts | 3 +-
surfsense_web/zero/schema/chat.ts | 6 +-
8 files changed, 82 insertions(+), 89 deletions(-)
delete mode 100644 surfsense_backend/alembic/versions/175_publish_thread_metadata_to_zero.py
diff --git a/surfsense_backend/alembic/versions/175_publish_thread_metadata_to_zero.py b/surfsense_backend/alembic/versions/175_publish_thread_metadata_to_zero.py
deleted file mode 100644
index d4d298a4a..000000000
--- a/surfsense_backend/alembic/versions/175_publish_thread_metadata_to_zero.py
+++ /dev/null
@@ -1,23 +0,0 @@
-"""publish thread metadata to zero_publication
-
-Revision ID: 175
-Revises: 174
-"""
-
-from collections.abc import Sequence
-
-from alembic import op
-from app.zero_publication import apply_publication
-
-revision: str = "175"
-down_revision: str | None = "174"
-branch_labels: str | Sequence[str] | None = None
-depends_on: str | Sequence[str] | None = None
-
-
-def upgrade() -> None:
- apply_publication(op.get_bind())
-
-
-def downgrade() -> None:
- """No-op. Historical publication shapes are immutable."""
diff --git a/surfsense_backend/app/zero_publication.py b/surfsense_backend/app/zero_publication.py
index f6dff6cdf..ea4d1c90c 100644
--- a/surfsense_backend/app/zero_publication.py
+++ b/surfsense_backend/app/zero_publication.py
@@ -57,12 +57,12 @@ AUTOMATION_COLS = [
"workspace_id",
]
+# Only the columns Zero needs as the authz *parent* of chat messages/comments/
+# session-state (``whereExists("thread")`` + space constraint). Tab titles and
+# visibility are resolved over REST (react-query), not pushed via Zero.
NEW_CHAT_THREAD_COLS = [
"id",
"workspace_id",
- "title",
- "visibility",
- "created_by_id",
]
# Enough to drive the lifecycle UI by push: status, the reviewable brief, and
diff --git a/surfsense_web/hooks/use-resolved-tabs.test.ts b/surfsense_web/hooks/use-resolved-tabs.test.ts
index 34a51df86..dfad6a5a6 100644
--- a/surfsense_web/hooks/use-resolved-tabs.test.ts
+++ b/surfsense_web/hooks/use-resolved-tabs.test.ts
@@ -1,30 +1,24 @@
import assert from "node:assert/strict";
import { test } from "node:test";
-import {
- getMissingCompleteChatIds,
- resolveTabPointers,
- type ResolvedTab,
-} from "./use-resolved-tabs";
+import { getMissingChatIds, resolveTabPointers, type ResolvedTab } from "./use-resolved-tabs";
// Run with: pnpm exec tsx --test hooks/use-resolved-tabs.test.ts
-test("does not prune unresolved chat tabs before Zero completes", () => {
- const missing = getMissingCompleteChatIds({
+test("does not prune chat tabs that have not resolved as missing", () => {
+ const missing = getMissingChatIds({
tabs: [{ id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 }],
- threadRows: [],
- resultType: "unknown",
+ notFoundIds: new Set(),
});
assert.equal(missing.size, 0);
});
-test("prunes unresolved chat tabs only after Zero completes", () => {
- const missing = getMissingCompleteChatIds({
+test("prunes only chat tabs whose thread resolved as not found", () => {
+ const missing = getMissingChatIds({
tabs: [
{ id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 },
{ id: "chat-43", type: "chat", entityId: 43, workspaceId: 7 },
],
- threadRows: [{ id: 43, title: "Present", visibility: "PRIVATE" }],
- resultType: "complete",
+ notFoundIds: new Set([42]),
});
assert.deepEqual([...missing], [42]);
diff --git a/surfsense_web/hooks/use-resolved-tabs.ts b/surfsense_web/hooks/use-resolved-tabs.ts
index 1b072857a..112d82983 100644
--- a/surfsense_web/hooks/use-resolved-tabs.ts
+++ b/surfsense_web/hooks/use-resolved-tabs.ts
@@ -1,11 +1,19 @@
"use client";
-import { useQuery } from "@rocicorp/zero/react";
+import { useQueries } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { pruneMissingChatTabsAtom, type Tab, tabsAtom } from "@/atoms/tabs/tabs.atom";
-import type { ChatVisibility } from "@/lib/chat/thread-persistence";
-import { queries } from "@/zero/queries";
+import { documentsApiService } from "@/lib/apis/documents-api.service";
+import { type ChatVisibility, getThreadFull } from "@/lib/chat/thread-persistence";
+import { NotFoundError } from "@/lib/error";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+
+// Thread/document metadata is read-only for tabs: the DB is the single source
+// of truth and react-query is the cache. Titles/visibility stay fresh because
+// the rename/visibility/delete mutations patch these same query caches (see
+// lib/chat/thread-cache.ts), so a rename reflects in the tab bar immediately.
+const METADATA_STALE_TIME_MS = 60 * 1000;
interface ThreadRow {
id: number;
@@ -38,6 +46,13 @@ function rowById(rows: readonly T[] | undefined): Map<
return new Map((rows ?? []).map((row) => [row.id, row]));
}
+// Retry transient failures (network, 5xx) but never a definitive 404 — a
+// missing thread/document is authoritative and should settle immediately so
+// the tab can be pruned rather than spun on.
+function retryUnlessNotFound(failureCount: number, error: Error): boolean {
+ return !(error instanceof NotFoundError) && failureCount < 2;
+}
+
export function getChatUrl(workspaceId: number, threadId: number | null): string {
return threadId
? `/dashboard/${workspaceId}/new-chat/${threadId}`
@@ -59,7 +74,9 @@ export function resolveTabPointers({
return tabs.map((tab) => {
if (tab.type === "document") {
const title =
- tab.entityId === null ? "Document" : (documents.get(tab.entityId)?.title ?? `Document ${tab.entityId}`);
+ tab.entityId === null
+ ? "Document"
+ : (documents.get(tab.entityId)?.title ?? `Document ${tab.entityId}`);
return { ...tab, title };
}
@@ -73,23 +90,23 @@ export function resolveTabPointers({
});
}
-export function getMissingCompleteChatIds({
+/**
+ * A chat tab is prunable only once its thread metadata fetch has settled as a
+ * definitive 404 (thread deleted). Transient network/5xx errors are excluded so
+ * an outage never wrongly closes open tabs.
+ */
+export function getMissingChatIds({
tabs,
- threadRows,
- resultType,
+ notFoundIds,
}: {
tabs: Tab[];
- threadRows?: readonly ThreadRow[];
- resultType: string;
+ notFoundIds: Set;
}): Set {
- if (resultType !== "complete") return new Set();
-
- const threadIds = new Set((threadRows ?? []).map((row) => row.id));
return new Set(
tabs
.filter(
(tab): tab is Tab & { type: "chat"; entityId: number } =>
- tab.type === "chat" && tab.entityId !== null && !threadIds.has(tab.entityId)
+ tab.type === "chat" && tab.entityId !== null && notFoundIds.has(tab.entityId)
)
.map((tab) => tab.entityId)
);
@@ -101,29 +118,48 @@ export function useResolvedTabs(): ResolvedTab[] {
const chatIds = useMemo(() => uniqueEntityIds(tabs, "chat"), [tabs]);
const documentIds = useMemo(() => uniqueEntityIds(tabs, "document"), [tabs]);
- const [threadRows, threadResult] = useQuery(
- queries.threads.byIds({ ids: chatIds.length > 0 ? chatIds : [-1] })
+
+ const threadResults = useQueries({
+ queries: chatIds.map((id) => ({
+ queryKey: cacheKeys.threads.detail(id),
+ queryFn: () => getThreadFull(id),
+ staleTime: METADATA_STALE_TIME_MS,
+ retry: retryUnlessNotFound,
+ })),
+ });
+
+ const documentResults = useQueries({
+ queries: documentIds.map((id) => ({
+ queryKey: cacheKeys.documents.document(String(id)),
+ queryFn: () => documentsApiService.getDocument({ id }),
+ staleTime: METADATA_STALE_TIME_MS,
+ retry: retryUnlessNotFound,
+ })),
+ });
+
+ const threadRows: ThreadRow[] = threadResults.flatMap((result, index) =>
+ result.data
+ ? [{ id: chatIds[index], title: result.data.title, visibility: result.data.visibility }]
+ : []
);
- const [documentRows] = useQuery(
- queries.documents.byIds({ ids: documentIds.length > 0 ? documentIds : [-1] })
+ const documentRows: DocumentRow[] = documentResults.flatMap((result, index) =>
+ result.data ? [{ id: documentIds[index], title: result.data.title }] : []
);
- const missingChatIds = useMemo(
- () =>
- getMissingCompleteChatIds({
- tabs,
- threadRows,
- resultType: threadResult.type,
- }),
- [tabs, threadRows, threadResult.type]
- );
+ // Stable primitive key of the threads that settled as 404, so the prune
+ // effect fires only when that set changes — not on every react-query render.
+ const notFoundChatIdsKey = threadResults
+ .flatMap((result, index) => (result.error instanceof NotFoundError ? [chatIds[index]] : []))
+ .sort((a, b) => a - b)
+ .join(",");
useEffect(() => {
- pruneMissingChatTabs(missingChatIds);
- }, [missingChatIds, pruneMissingChatTabs]);
+ const notFoundIds = new Set(
+ notFoundChatIdsKey ? notFoundChatIdsKey.split(",").map(Number) : []
+ );
+ const missing = getMissingChatIds({ tabs, notFoundIds });
+ if (missing.size > 0) pruneMissingChatTabs(missing);
+ }, [notFoundChatIdsKey, tabs, pruneMissingChatTabs]);
- return useMemo(
- () => resolveTabPointers({ tabs, threadRows, documentRows }),
- [tabs, threadRows, documentRows]
- );
+ return resolveTabPointers({ tabs, threadRows, documentRows });
}
diff --git a/surfsense_web/zero/queries/chat.ts b/surfsense_web/zero/queries/chat.ts
index 06969a4e9..40e09a6ee 100644
--- a/surfsense_web/zero/queries/chat.ts
+++ b/surfsense_web/zero/queries/chat.ts
@@ -29,13 +29,3 @@ export const chatSessionQueries = {
.one()
),
};
-
-export const threadQueries = {
- byIds: defineQuery(z.object({ ids: z.array(z.number()) }), ({ args: { ids }, ctx }) =>
- constrainToAllowedSpaces(zql.new_chat_threads, ctx)
- .where("id", "IN", ids)
- .where(({ or, cmp }) =>
- or(cmp("createdById", ctx?.userId ?? ""), cmp("visibility", "SEARCH_SPACE"))
- )
- ),
-};
diff --git a/surfsense_web/zero/queries/documents.ts b/surfsense_web/zero/queries/documents.ts
index 6c110aee2..d2f483989 100644
--- a/surfsense_web/zero/queries/documents.ts
+++ b/surfsense_web/zero/queries/documents.ts
@@ -9,9 +9,6 @@ export const documentQueries = {
if (!canReadSpace(ctx, workspaceId)) return denySpace(query).orderBy("createdAt", "desc");
return constrainToAllowedSpaces(query, ctx).orderBy("createdAt", "desc");
}),
- byIds: defineQuery(z.object({ ids: z.array(z.number()) }), ({ args: { ids }, ctx }) =>
- constrainToAllowedSpaces(zql.documents, ctx).where("id", "IN", ids)
- ),
};
export const connectorQueries = {
diff --git a/surfsense_web/zero/queries/index.ts b/surfsense_web/zero/queries/index.ts
index 2e51bf9a8..45df8fa98 100644
--- a/surfsense_web/zero/queries/index.ts
+++ b/surfsense_web/zero/queries/index.ts
@@ -1,6 +1,6 @@
import { defineQueries } from "@rocicorp/zero";
import { automationRunQueries } from "./automations";
-import { chatSessionQueries, commentQueries, messageQueries, threadQueries } from "./chat";
+import { chatSessionQueries, commentQueries, messageQueries } from "./chat";
import { connectorQueries, documentQueries } from "./documents";
import { folderQueries } from "./folders";
import { notificationQueries } from "./inbox";
@@ -15,7 +15,6 @@ export const queries = defineQueries({
messages: messageQueries,
comments: commentQueries,
chatSession: chatSessionQueries,
- threads: threadQueries,
user: userQueries,
automationRuns: automationRunQueries,
podcasts: podcastQueries,
diff --git a/surfsense_web/zero/schema/chat.ts b/surfsense_web/zero/schema/chat.ts
index 81673feaa..c2790d5bc 100644
--- a/surfsense_web/zero/schema/chat.ts
+++ b/surfsense_web/zero/schema/chat.ts
@@ -20,13 +20,13 @@ export const newChatMessageTable = table("new_chat_messages")
})
.primaryKey("id");
+// Published only as the authz parent of chat messages/comments/session-state
+// (whereExists("thread") + space constraint). Title/visibility are resolved
+// over REST (react-query) for the tab bar, not synced through Zero.
export const newChatThreadTable = table("new_chat_threads")
.columns({
id: number(),
workspaceId: number().from("workspace_id"),
- title: string(),
- visibility: string(),
- createdById: string().optional().from("created_by_id"),
})
.primaryKey("id");
From fd6e54f12274623fe551a9b51bb7b863cbced289 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:58:13 +0530
Subject: [PATCH 133/217] refactor: remove test file for use-resolved-tabs to
streamline codebase
---
surfsense_web/hooks/use-resolved-tabs.test.ts | 47 -------------------
1 file changed, 47 deletions(-)
delete mode 100644 surfsense_web/hooks/use-resolved-tabs.test.ts
diff --git a/surfsense_web/hooks/use-resolved-tabs.test.ts b/surfsense_web/hooks/use-resolved-tabs.test.ts
deleted file mode 100644
index dfad6a5a6..000000000
--- a/surfsense_web/hooks/use-resolved-tabs.test.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import assert from "node:assert/strict";
-import { test } from "node:test";
-import { getMissingChatIds, resolveTabPointers, type ResolvedTab } from "./use-resolved-tabs";
-
-// Run with: pnpm exec tsx --test hooks/use-resolved-tabs.test.ts
-test("does not prune chat tabs that have not resolved as missing", () => {
- const missing = getMissingChatIds({
- tabs: [{ id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 }],
- notFoundIds: new Set(),
- });
-
- assert.equal(missing.size, 0);
-});
-
-test("prunes only chat tabs whose thread resolved as not found", () => {
- const missing = getMissingChatIds({
- tabs: [
- { id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 },
- { id: "chat-43", type: "chat", entityId: 43, workspaceId: 7 },
- ],
- notFoundIds: new Set([42]),
- });
-
- assert.deepEqual([...missing], [42]);
-});
-
-test("merges pointer tabs with synced row titles", () => {
- const resolved = resolveTabPointers({
- tabs: [
- { id: "chat-42", type: "chat", entityId: 42, workspaceId: 7 },
- { id: "doc-9", type: "document", entityId: 9, workspaceId: 7 },
- ],
- threadRows: [{ id: 42, title: "Live chat title", visibility: "SEARCH_SPACE" }],
- documentRows: [{ id: 9, title: "Live document title" }],
- });
-
- assert.deepEqual(
- resolved.map((tab): Pick => ({
- id: tab.id,
- title: tab.title,
- })),
- [
- { id: "chat-42", title: "Live chat title" },
- { id: "doc-9", title: "Live document title" },
- ]
- );
-});
From ddfddb2d834d11980894baea6f2372b47b7d618d Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:58:31 +0530
Subject: [PATCH 134/217] refactor: update comments and remove staleTime from
useResolvedTabs for clarity and consistency
---
surfsense_web/hooks/use-resolved-tabs.ts | 11 ++++-------
1 file changed, 4 insertions(+), 7 deletions(-)
diff --git a/surfsense_web/hooks/use-resolved-tabs.ts b/surfsense_web/hooks/use-resolved-tabs.ts
index 112d82983..99bc90100 100644
--- a/surfsense_web/hooks/use-resolved-tabs.ts
+++ b/surfsense_web/hooks/use-resolved-tabs.ts
@@ -10,11 +10,10 @@ import { NotFoundError } from "@/lib/error";
import { cacheKeys } from "@/lib/query-client/cache-keys";
// Thread/document metadata is read-only for tabs: the DB is the single source
-// of truth and react-query is the cache. Titles/visibility stay fresh because
-// the rename/visibility/delete mutations patch these same query caches (see
-// lib/chat/thread-cache.ts), so a rename reflects in the tab bar immediately.
-const METADATA_STALE_TIME_MS = 60 * 1000;
-
+// of truth and react-query is the cache (default staleTime from the shared
+// QueryClient). Titles/visibility stay fresh because the rename/visibility/
+// delete mutations patch these same query caches (see lib/chat/thread-cache.ts),
+// so a rename reflects in the tab bar immediately regardless of staleness.
interface ThreadRow {
id: number;
title: string;
@@ -123,7 +122,6 @@ export function useResolvedTabs(): ResolvedTab[] {
queries: chatIds.map((id) => ({
queryKey: cacheKeys.threads.detail(id),
queryFn: () => getThreadFull(id),
- staleTime: METADATA_STALE_TIME_MS,
retry: retryUnlessNotFound,
})),
});
@@ -132,7 +130,6 @@ export function useResolvedTabs(): ResolvedTab[] {
queries: documentIds.map((id) => ({
queryKey: cacheKeys.documents.document(String(id)),
queryFn: () => documentsApiService.getDocument({ id }),
- staleTime: METADATA_STALE_TIME_MS,
retry: retryUnlessNotFound,
})),
});
From 609adea006a6080e7279b1c57b1abee072844a1e Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 22 Jul 2026 19:34:56 +0200
Subject: [PATCH 135/217] feat: add RUN citation source type
---
.../app/agents/chat/multi_agent_chat/shared/citations/models.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/citations/models.py b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/citations/models.py
index 1273271af..31a0e3372 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/citations/models.py
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/citations/models.py
@@ -17,6 +17,7 @@ class CitationSourceType(StrEnum):
WEB_RESULT = "web_result"
CHAT_TURN = "chat_turn"
ANON_CHUNK = "anon_chunk"
+ RUN = "run"
class CitationEntry(BaseModel):
From 81f2d694bd6e2ec37f46197dbbf2ac69ccd24379 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 22 Jul 2026 19:34:56 +0200
Subject: [PATCH 136/217] feat: map run citation to run_ payload
---
.../multi_agent_chat/shared/citations/markers.py | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/citations/markers.py b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/citations/markers.py
index 025d364f6..61d1cbd8b 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/shared/citations/markers.py
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/shared/citations/markers.py
@@ -1,10 +1,11 @@
"""Map a registered citation to the frontend ``[citation:]`` payload.
The citation renderer understands a chunk id (``42``), a negative chunk id for
-anonymous uploads (``-3``), and a URL. This is the seam that turns a server-side
-source into one the renderer can resolve; it grows as more source kinds become
-renderable. Kinds with no renderable form yet return ``None`` so the marker is
-dropped rather than emitted broken.
+anonymous uploads (``-3``), a URL, and a scraper-run handle (``run_``).
+This is the seam that turns a server-side source into one the renderer can
+resolve; it grows as more source kinds become renderable. Kinds with no
+renderable form yet return ``None`` so the marker is dropped rather than
+emitted broken.
"""
from __future__ import annotations
@@ -22,6 +23,9 @@ def to_frontend_payload(entry: CitationEntry) -> str | None:
case CitationSourceType.WEB_RESULT:
url = locator.get("url")
return url or None
+ case CitationSourceType.RUN:
+ run_id = locator.get("run_id")
+ return str(run_id) if run_id else None
case _:
# Connector items and chat turns have no client-side renderer yet
# (the frontend resolves only chunk ids and URLs), so they stay
From 957e57bb82b8c71e627640a6fe32912a0532c761 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 22 Jul 2026 19:34:56 +0200
Subject: [PATCH 137/217] feat: register a scraper run as a citation
---
.../capabilities/core/access/run_citation.py | 23 +++++++++++++++++++
1 file changed, 23 insertions(+)
create mode 100644 surfsense_backend/app/capabilities/core/access/run_citation.py
diff --git a/surfsense_backend/app/capabilities/core/access/run_citation.py b/surfsense_backend/app/capabilities/core/access/run_citation.py
new file mode 100644
index 000000000..9b7c3f3db
--- /dev/null
+++ b/surfsense_backend/app/capabilities/core/access/run_citation.py
@@ -0,0 +1,23 @@
+"""Register a recorded scraper run as a citable ``[n]``."""
+
+from __future__ import annotations
+
+from app.agents.chat.multi_agent_chat.shared.citations import (
+ CitationRegistry,
+ CitationSourceType,
+)
+
+
+def attach_run_citation(
+ registry: CitationRegistry,
+ *,
+ run_external_id: str,
+ capability: str,
+) -> tuple[int, str]:
+ """Register the ``run_`` handle; return its ``[n]`` and the label line."""
+ n = registry.register(
+ CitationSourceType.RUN,
+ {"run_id": run_external_id},
+ {"capability": capability},
+ )
+ return n, f"\n\nCite this scraper run as [{n}] after any claim drawn from its data."
From c1ba4215fd3af4a102fd8efb19acd6d369dc859a Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 22 Jul 2026 19:34:56 +0200
Subject: [PATCH 138/217] feat: mint run citation from capability tool
---
.../app/capabilities/core/access/agent.py | 40 ++++++++++++++++---
1 file changed, 35 insertions(+), 5 deletions(-)
diff --git a/surfsense_backend/app/capabilities/core/access/agent.py b/surfsense_backend/app/capabilities/core/access/agent.py
index 4aa569e24..35c35774f 100644
--- a/surfsense_backend/app/capabilities/core/access/agent.py
+++ b/surfsense_backend/app/capabilities/core/access/agent.py
@@ -11,9 +11,13 @@ subagent can follow a truncation reference without extra wiring.
from __future__ import annotations
+import json
import time
+from langchain.tools import ToolRuntime
+from langchain_core.messages import ToolMessage
from langchain_core.tools import BaseTool, StructuredTool
+from langgraph.types import Command
from app.capabilities.core.billing import charge_capability, gate_capability
from app.capabilities.core.progress import progress_scope
@@ -64,7 +68,7 @@ def _capability_tool(capability: Capability, workspace_id: int) -> BaseTool:
executor = capability.executor
name = capability.name
- async def _run(**kwargs: object) -> dict | str:
+ async def _run(runtime: ToolRuntime, **kwargs: object) -> dict | str | Command:
payload = input_model(**kwargs)
input_dump = payload.model_dump(exclude_none=True)
thread_id = _current_thread_id()
@@ -119,13 +123,39 @@ def _capability_tool(capability: Capability, workspace_id: int) -> BaseTool:
progress=reporter.coarse,
)
+ # No stored run to cite: keep the legacy return shape, no citation.
+ if run_id is None:
+ if serialized.char_count <= RUN_OUTPUT_CHAR_CAP:
+ return output.model_dump(exclude_none=True)
+ return _build_preview(serialized, run_id)
+
+ run_external_id = f"run_{run_id}"
if serialized.char_count <= RUN_OUTPUT_CHAR_CAP:
dump = output.model_dump(exclude_none=True)
- if run_id is not None:
- dump["run_id"] = f"run_{run_id}"
- return dump
+ dump["run_id"] = run_external_id
+ content = json.dumps(dump, ensure_ascii=False, default=str)
+ else:
+ content = _build_preview(serialized, run_id)
- return _build_preview(serialized, run_id)
+ # Deferred import: the citation spine imports from here; lazy avoids a cycle.
+ from app.agents.chat.multi_agent_chat.shared.citations import load_registry
+ from app.capabilities.core.access.run_citation import attach_run_citation
+
+ registry = load_registry(getattr(runtime, "state", None))
+ _, label = attach_run_citation(
+ registry, run_external_id=run_external_id, capability=name
+ )
+ return Command(
+ update={
+ "messages": [
+ ToolMessage(
+ content=content + label,
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ "citation_registry": registry,
+ }
+ )
return StructuredTool.from_function(
coroutine=_run,
From 95b4afddebcb72d6bdfd80212171c70504496032 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 22 Jul 2026 19:34:56 +0200
Subject: [PATCH 139/217] feat: declare citation channel for subagents
---
.../chat/multi_agent_chat/subagents/middleware_stack.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/middleware_stack.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/middleware_stack.py
index 124ccf704..4181180d1 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/middleware_stack.py
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/middleware_stack.py
@@ -15,6 +15,9 @@ from __future__ import annotations
from typing import Any
from app.agents.chat.multi_agent_chat.shared.feature_flags import AgentFeatureFlags
+from app.agents.chat.multi_agent_chat.shared.middleware.citation_state import (
+ build_citation_state_mw,
+)
from app.agents.chat.multi_agent_chat.shared.middleware.resilience import (
ResilienceMiddlewares,
)
@@ -45,6 +48,8 @@ def build_subagent_middleware_stack(
return {
"todos": build_todos_mw(),
"permission": permission,
+ # Declares the citation_registry channel so run [n]s merge up from tools.
+ "citation": build_citation_state_mw(),
"retry": resilience.retry,
"fallback": resilience.fallback,
"model_call_limit": resilience.model_call_limit,
From dd5d267056ef623a2a78381e4c7edc49e3a30735 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 22 Jul 2026 19:34:56 +0200
Subject: [PATCH 140/217] docs: instruct agents to cite scraper runs
---
.../main_agent/system_prompt/prompts/citations/on.md | 5 +++--
.../subagents/shared/snippets/output_contract_base.md | 1 +
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/citations/on.md b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/citations/on.md
index 371cd1e57..effe5078c 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/citations/on.md
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/citations/on.md
@@ -1,8 +1,9 @@
Cite with one token: the bracket label `[n]`. Every citable result —
prose from a `task` knowledge_base/research specialist (including the
-knowledge_base specialist's `[n]`-labelled workspace findings) — already
-carries `[n]` labels on a single shared count.
+knowledge_base specialist's `[n]`-labelled workspace findings) and
+scraper specialists' run-backed findings — already carries `[n]` labels
+on a single shared count.
Those labels are the only citation you write; the server resolves each one
back to its source after the turn.
diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/shared/snippets/output_contract_base.md b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/shared/snippets/output_contract_base.md
index 35fd48814..cd3b94400 100644
--- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/shared/snippets/output_contract_base.md
+++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/shared/snippets/output_contract_base.md
@@ -5,3 +5,4 @@ Rules (universal):
- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null.
- `assumptions`: any inferences you made about the user's intent; `null` when no inferences were needed.
- The `evidence` object's fields are documented in your route-specific `` above; never invent fields the tool did not return.
+- When a finding is drawn from a scraper run, append that run's `[n]` (the tool result states `Cite this scraper run as [n]`) to the finding text so the citation survives into the final answer. Copy the label exactly; never invent one.
From 57d6182ca3f20be86d1c62d092e03abfdd82e44f Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 22 Jul 2026 19:35:04 +0200
Subject: [PATCH 141/217] feat: parse run citation tokens
---
surfsense_web/lib/citations/citation-parser.ts | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/surfsense_web/lib/citations/citation-parser.ts b/surfsense_web/lib/citations/citation-parser.ts
index 533c644c2..3c2f0196c 100644
--- a/surfsense_web/lib/citations/citation-parser.ts
+++ b/surfsense_web/lib/citations/citation-parser.ts
@@ -11,18 +11,20 @@ import { FENCED_OR_INLINE_CODE } from "@/lib/markdown/code-regions";
/**
* Matches `[citation:...]` with numeric IDs (incl. negative, doc- prefix,
- * comma-separated), URL-based IDs from live web search, or `urlciteN`
- * placeholders produced by `preprocessCitationMarkdown`.
+ * comma-separated), URL-based IDs from live web search, `urlciteN`
+ * placeholders produced by `preprocessCitationMarkdown`, or a scraper-run
+ * handle (`run_`).
*
* Also matches Chinese brackets 【】 and zero-width spaces that LLMs
* sometimes emit.
*/
export const CITATION_REGEX =
- /[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+|urlcite\d+|(?:doc-)?-?\d+(?:\s*,\s*(?:doc-)?-?\d+)*)\s*\u200B?[\]】]/g;
+ /[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+|urlcite\d+|run_[0-9a-fA-F-]+|(?:doc-)?-?\d+(?:\s*,\s*(?:doc-)?-?\d+)*)\s*\u200B?[\]】]/g;
/** A single parsed citation reference. */
export type CitationToken =
| { kind: "url"; url: string }
+ | { kind: "run"; runId: string }
| { kind: "chunk"; chunkId: number; isDocsChunk: boolean };
/** Output of `parseTextWithCitations` — interleaved text + citation tokens. */
@@ -102,6 +104,8 @@ export function parseTextWithCitations(text: string, urlMap: CitationUrlMap): Pa
if (url) {
segments.push({ kind: "url", url });
}
+ } else if (captured.startsWith("run_")) {
+ segments.push({ kind: "run", runId: captured.trim() });
} else {
const rawIds = captured.split(",").map((s) => s.trim());
for (const rawId of rawIds) {
From 979ad02ef3006791b41230516f6ae71fb7091e76 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 22 Jul 2026 19:35:04 +0200
Subject: [PATCH 142/217] feat: render run citation chip
---
.../citations/citation-renderer.tsx | 4 +++
.../components/citations/run-citation.tsx | 31 +++++++++++++++++++
2 files changed, 35 insertions(+)
create mode 100644 surfsense_web/components/citations/run-citation.tsx
diff --git a/surfsense_web/components/citations/citation-renderer.tsx b/surfsense_web/components/citations/citation-renderer.tsx
index f2de4b27d..d3d78aa39 100644
--- a/surfsense_web/components/citations/citation-renderer.tsx
+++ b/surfsense_web/components/citations/citation-renderer.tsx
@@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
+import { RunCitation } from "@/components/citations/run-citation";
import {
type CitationToken,
type CitationUrlMap,
@@ -21,6 +22,9 @@ export function renderCitationToken(token: CitationToken, ordinalKey: number): R
if (token.kind === "url") {
return ;
}
+ if (token.kind === "run") {
+ return ;
+ }
return (
= ({ runId }) => {
+ const openRunPanel = useSetAtom(openRunCitationPanelAtom);
+
+ return (
+
+
+ openRunPanel({ runId })}
+ className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline"
+ aria-label="View scraper run"
+ >
+
+ run
+
+
+ View scraper run
+
+ );
+};
From b86eadb8474069d7756ea383e738a1e592c7313f Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 22 Jul 2026 19:35:04 +0200
Subject: [PATCH 143/217] feat: open runs in the citation panel
---
.../atoms/citation/citation-panel.atom.ts | 26 ++++++----
.../citations/run-citation-panel.tsx | 47 +++++++++++++++++++
.../layout/ui/right-panel/RightPanel.tsx | 28 +++++++++--
3 files changed, 88 insertions(+), 13 deletions(-)
create mode 100644 surfsense_web/components/citations/run-citation-panel.tsx
diff --git a/surfsense_web/atoms/citation/citation-panel.atom.ts b/surfsense_web/atoms/citation/citation-panel.atom.ts
index ca7312857..f92fd6f90 100644
--- a/surfsense_web/atoms/citation/citation-panel.atom.ts
+++ b/surfsense_web/atoms/citation/citation-panel.atom.ts
@@ -1,14 +1,19 @@
-import { atom } from "jotai";
+import { atom, type Getter, type Setter } from "jotai";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
+/** The source the citation panel is showing: a KB chunk or a scraper run. */
+export type CitationTarget =
+ | { kind: "chunk"; chunkId: number }
+ | { kind: "run"; runId: string };
+
interface CitationPanelState {
isOpen: boolean;
- chunkId: number | null;
+ target: CitationTarget | null;
}
const initialState: CitationPanelState = {
isOpen: false,
- chunkId: null,
+ target: null,
};
export const citationPanelAtom = atom(initialState);
@@ -17,16 +22,21 @@ export const citationPanelOpenAtom = atom((get) => get(citationPanelAtom).isOpen
const preCitationCollapsedAtom = atom(null);
-export const openCitationPanelAtom = atom(null, (get, set, payload: { chunkId: number }) => {
+function openWithTarget(get: Getter, set: Setter, target: CitationTarget) {
if (!get(citationPanelAtom).isOpen) {
set(preCitationCollapsedAtom, get(rightPanelCollapsedAtom));
}
- set(citationPanelAtom, {
- isOpen: true,
- chunkId: payload.chunkId,
- });
+ set(citationPanelAtom, { isOpen: true, target });
set(rightPanelTabAtom, "citation");
set(rightPanelCollapsedAtom, false);
+}
+
+export const openCitationPanelAtom = atom(null, (get, set, payload: { chunkId: number }) => {
+ openWithTarget(get, set, { kind: "chunk", chunkId: payload.chunkId });
+});
+
+export const openRunCitationPanelAtom = atom(null, (get, set, payload: { runId: string }) => {
+ openWithTarget(get, set, { kind: "run", runId: payload.runId });
});
export const closeCitationPanelAtom = atom(null, (get, set) => {
diff --git a/surfsense_web/components/citations/run-citation-panel.tsx b/surfsense_web/components/citations/run-citation-panel.tsx
new file mode 100644
index 000000000..3b7af7ca1
--- /dev/null
+++ b/surfsense_web/components/citations/run-citation-panel.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { XIcon } from "lucide-react";
+import { useParams } from "next/navigation";
+import type { FC } from "react";
+import { RunDetail } from "@/app/dashboard/[workspace_id]/playground/components/run-detail";
+import { Button } from "@/components/ui/button";
+
+/** Right-panel viewer for a cited scraper run. `runId` is the `run_` handle. */
+export const RunCitationPanelContent: FC<{ runId: string; onClose?: () => void }> = ({
+ runId,
+ onClose,
+}) => {
+ const params = useParams();
+ const rawWorkspaceId = Array.isArray(params?.workspace_id)
+ ? params.workspace_id[0]
+ : params?.workspace_id;
+ const workspaceId = Number(rawWorkspaceId);
+ const scraperRunId = runId.replace(/^run_/, "");
+
+ return (
+ <>
+
+
Scraper run
+ {onClose && (
+
+
+ Close run panel
+
+ )}
+
+
+
+ {Number.isFinite(workspaceId) ? (
+
+ ) : (
+
Open a workspace to view this run.
+ )}
+
+ >
+ );
+};
diff --git a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx
index 15025f4eb..362e676d8 100644
--- a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx
+++ b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx
@@ -35,6 +35,14 @@ const CitationPanelContent = dynamic(
{ ssr: false, loading: () => null }
);
+const RunCitationPanelContent = dynamic(
+ () =>
+ import("@/components/citations/run-citation-panel").then((m) => ({
+ default: m.RunCitationPanelContent,
+ })),
+ { ssr: false, loading: () => null }
+);
+
const HitlEditPanelContent = dynamic(
() =>
import("@/features/chat-messages/hitl").then((m) => ({
@@ -89,7 +97,7 @@ export function RightPanelToggleButton({
? !!editorState.memoryScope
: !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
- const citationOpen = citationState.isOpen && citationState.chunkId != null;
+ const citationOpen = citationState.isOpen && citationState.target != null;
const hasContent = reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen;
const label = collapsed ? "Expand panel" : "Collapse panel";
@@ -141,7 +149,7 @@ export function RightPanelExpandButton() {
? !!editorState.memoryScope
: !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
- const citationOpen = citationState.isOpen && citationState.chunkId != null;
+ const citationOpen = citationState.isOpen && citationState.target != null;
const hasContent = reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen;
if (!collapsed || !hasContent) return null;
@@ -212,7 +220,7 @@ export function RightPanel({ showTopBorder = false }: RightPanelProps) {
? !!editorState.memoryScope
: !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
- const citationOpen = citationState.isOpen && citationState.chunkId != null;
+ const citationOpen = citationState.isOpen && citationState.target != null;
useEffect(() => {
if (!reportOpen && !editorOpen && !hitlEditOpen && !citationOpen && !artifactsOpen) return;
@@ -306,9 +314,19 @@ export function RightPanel({ showTopBorder = false }: RightPanelProps) {
/>
)}
- {effectiveTab === "citation" && citationOpen && citationState.chunkId != null && (
+ {effectiveTab === "citation" && citationOpen && citationState.target && (
-
+ {citationState.target.kind === "run" ? (
+
+ ) : (
+
+ )}
)}
{effectiveTab === "artifacts" && artifactsOpen && (
From 120eb1f31b155e9cd2d27bec71b503ab8c8644a6 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 22 Jul 2026 19:35:04 +0200
Subject: [PATCH 144/217] test: cover run citation registration
---
.../shared/citations/test_markers.py | 12 ++++++
.../capabilities/access/test_run_citation.py | 43 +++++++++++++++++++
2 files changed, 55 insertions(+)
create mode 100644 surfsense_backend/tests/unit/capabilities/access/test_run_citation.py
diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/shared/citations/test_markers.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/shared/citations/test_markers.py
index 53cf058a8..5710bc7a6 100644
--- a/surfsense_backend/tests/unit/agents/multi_agent_chat/shared/citations/test_markers.py
+++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/shared/citations/test_markers.py
@@ -37,6 +37,18 @@ def test_web_result_maps_to_url() -> None:
assert to_frontend_payload(entry) == "https://example.com/a"
+def test_run_maps_to_run_handle() -> None:
+ entry = _entry(CitationSourceType.RUN, {"run_id": "run_abc-123"})
+
+ assert to_frontend_payload(entry) == "run_abc-123"
+
+
+def test_run_without_handle_is_dropped() -> None:
+ entry = _entry(CitationSourceType.RUN, {})
+
+ assert to_frontend_payload(entry) is None
+
+
def test_not_yet_renderable_kind_is_dropped() -> None:
entry = _entry(CitationSourceType.CHAT_TURN, {"thread_id": 1, "turn": 2})
diff --git a/surfsense_backend/tests/unit/capabilities/access/test_run_citation.py b/surfsense_backend/tests/unit/capabilities/access/test_run_citation.py
new file mode 100644
index 000000000..6da9e5f29
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/access/test_run_citation.py
@@ -0,0 +1,43 @@
+"""Unit tests for registering a scraper run as a citation."""
+
+from __future__ import annotations
+
+import pytest
+
+from app.agents.chat.multi_agent_chat.shared.citations import (
+ CitationRegistry,
+ CitationSourceType,
+ to_frontend_payload,
+)
+from app.capabilities.core.access.run_citation import attach_run_citation
+
+pytestmark = pytest.mark.unit
+
+
+def test_attaches_run_and_returns_label_with_ordinal() -> None:
+ registry = CitationRegistry()
+
+ n, label = attach_run_citation(
+ registry, run_external_id="run_abc-123", capability="walmart.scrape"
+ )
+
+ assert n == 1
+ assert f"[{n}]" in label
+ entry = registry.resolve(n)
+ assert entry is not None
+ assert entry.source_type is CitationSourceType.RUN
+ assert to_frontend_payload(entry) == "run_abc-123"
+
+
+def test_same_run_dedups_to_one_label() -> None:
+ registry = CitationRegistry()
+
+ first, _ = attach_run_citation(
+ registry, run_external_id="run_x", capability="walmart.scrape"
+ )
+ again, _ = attach_run_citation(
+ registry, run_external_id="run_x", capability="walmart.reviews"
+ )
+
+ assert first == again
+ assert len(registry.by_n) == 1
From d356605a0dccc96dca1ca84f55248e6f58a39b63 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 22 Jul 2026 19:35:04 +0200
Subject: [PATCH 145/217] test: capability tool mints run citation
---
.../capabilities/access/test_agent_tools.py | 32 +++++++++++++++++--
1 file changed, 29 insertions(+), 3 deletions(-)
diff --git a/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py b/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py
index cdab68bc0..fb8f0c540 100644
--- a/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py
+++ b/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py
@@ -69,6 +69,12 @@ def _verb_tool(tools, name: str):
return next(t for t in tools if t.name == name)
+def _invoke(tool, text: str, *, state=None):
+ """Call the coroutine with a stand-in runtime (ToolNode injects it in prod)."""
+ runtime = SimpleNamespace(state=state or {}, tool_call_id="tc_1", context=None)
+ return tool.coroutine(runtime, text=text)
+
+
async def test_registry_becomes_one_tool_per_verb_plus_readers(isolate):
caps = [
_capability(name="web.scrape", output=_EchoOutput(echoed="a")),
@@ -104,20 +110,40 @@ async def test_tool_runs_executor_and_returns_serialized_output(isolate):
tools = isolate.module.build_capability_tools(workspace_id=7, capabilities=[cap])
tool = _verb_tool(tools, "web_scrape")
- result = await tool.ainvoke({"text": "ping"})
+ result = await _invoke(tool, "ping")
# Fake session makes record_run fail -> no run_id key, plain serialized output.
assert result == {"echoed": "hi there"}
assert cap.executor.seen.text == "ping"
+async def test_tool_registers_run_citation_when_stored(isolate, monkeypatch):
+ from langgraph.types import Command
+
+ cap = _capability(name="web.scrape", output=_EchoOutput(echoed="hi"))
+ monkeypatch.setattr(isolate.module, "record_run", AsyncMock(return_value="abc-123"))
+ tools = isolate.module.build_capability_tools(workspace_id=7, capabilities=[cap])
+ tool = _verb_tool(tools, "web_scrape")
+
+ result = await _invoke(tool, "ping")
+
+ assert isinstance(result, Command)
+ registry = result.update["citation_registry"]
+ entry = registry.resolve(1)
+ assert entry is not None
+ assert entry.locator["run_id"] == "run_abc-123"
+ message = result.update["messages"][0]
+ assert "[1]" in message.content
+ assert "run_abc-123" in message.content
+
+
async def test_tool_charges_owner(isolate):
output = _EchoOutput(echoed="hi")
cap = _capability(name="web.scrape", output=output)
tools = isolate.module.build_capability_tools(workspace_id=7, capabilities=[cap])
tool = _verb_tool(tools, "web_scrape")
- await tool.ainvoke({"text": "ping"})
+ await _invoke(tool, "ping")
isolate.charge.assert_awaited_once()
(charged_output, unit, ctx), _ = isolate.charge.call_args
@@ -136,7 +162,7 @@ async def test_over_budget_returns_friendly_message(isolate):
tools = isolate.module.build_capability_tools(workspace_id=7, capabilities=[cap])
tool = _verb_tool(tools, "web_scrape")
- result = await tool.ainvoke({"text": "ping"})
+ result = await _invoke(tool, "ping")
assert isinstance(result, str)
assert "credit" in result.lower()
From 6c8d109d761efe91728bc58ade166bc0ef84423d Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 22 Jul 2026 19:35:04 +0200
Subject: [PATCH 146/217] test: parse run citation tokens
---
.../lib/citations/citation-parser.test.ts | 35 +++++++++++++++++++
1 file changed, 35 insertions(+)
create mode 100644 surfsense_web/lib/citations/citation-parser.test.ts
diff --git a/surfsense_web/lib/citations/citation-parser.test.ts b/surfsense_web/lib/citations/citation-parser.test.ts
new file mode 100644
index 000000000..1f5749f2f
--- /dev/null
+++ b/surfsense_web/lib/citations/citation-parser.test.ts
@@ -0,0 +1,35 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { parseTextWithCitations } from "./citation-parser";
+
+// Run with: pnpm exec tsx --test lib/citations/citation-parser.test.ts
+
+const NO_URLS = new Map();
+
+test("parses a scraper-run handle into a run token", () => {
+ const segments = parseTextWithCitations(
+ "Price is $19 [citation:run_1b2c3d4e-5f60-7081-9abc-def012345678].",
+ NO_URLS
+ );
+
+ assert.deepEqual(segments, [
+ "Price is $19 ",
+ { kind: "run", runId: "run_1b2c3d4e-5f60-7081-9abc-def012345678" },
+ ".",
+ ]);
+});
+
+test("run handles do not collide with numeric chunk citations", () => {
+ const segments = parseTextWithCitations(
+ "chunk [citation:42] vs run [citation:run_ab-12].",
+ NO_URLS
+ );
+
+ assert.deepEqual(segments, [
+ "chunk ",
+ { kind: "chunk", chunkId: 42, isDocsChunk: false },
+ " vs run ",
+ { kind: "run", runId: "run_ab-12" },
+ ".",
+ ]);
+});
From 950de5e670d919b7ee7076821de09772b7d7fb11 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 22 Jul 2026 20:06:18 +0200
Subject: [PATCH 147/217] fix: forward ToolRuntime to capability tools
---
surfsense_backend/app/capabilities/core/access/agent.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/surfsense_backend/app/capabilities/core/access/agent.py b/surfsense_backend/app/capabilities/core/access/agent.py
index 35c35774f..3d3864a53 100644
--- a/surfsense_backend/app/capabilities/core/access/agent.py
+++ b/surfsense_backend/app/capabilities/core/access/agent.py
@@ -157,6 +157,9 @@ def _capability_tool(capability: Capability, workspace_id: int) -> BaseTool:
}
)
+ # Un-stringify for StructuredTool's signature-based runtime injection.
+ _run.__annotations__["runtime"] = ToolRuntime
+
return StructuredTool.from_function(
coroutine=_run,
name=name.replace(".", "_"),
From 47d35faeedf91dca7dbb5416f1af6ea7f8873548 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 22 Jul 2026 20:06:18 +0200
Subject: [PATCH 148/217] test: guard capability tool runtime injection
---
.../unit/capabilities/access/test_agent_tools.py | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py b/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py
index fb8f0c540..3d4bed199 100644
--- a/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py
+++ b/surfsense_backend/tests/unit/capabilities/access/test_agent_tools.py
@@ -137,6 +137,17 @@ async def test_tool_registers_run_citation_when_stored(isolate, monkeypatch):
assert "run_abc-123" in message.content
+async def test_runtime_survives_langchain_arg_parsing(isolate):
+ """runtime must survive langchain arg parsing (else ToolNode drops it)."""
+ cap = _capability(name="web.scrape", output=_EchoOutput(echoed="hi"))
+ tools = isolate.module.build_capability_tools(workspace_id=7, capabilities=[cap])
+ tool = _verb_tool(tools, "web_scrape")
+
+ parsed = tool._parse_input({"text": "x", "runtime": "RT"}, "tc_1")
+
+ assert parsed["runtime"] == "RT"
+
+
async def test_tool_charges_owner(isolate):
output = _EchoOutput(echoed="hi")
cap = _capability(name="web.scrape", output=output)
From 3f23f436fb57180a05b1d93876f37790649dc853 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Wed, 22 Jul 2026 21:01:05 +0200
Subject: [PATCH 149/217] refactor(citations): rename run chip to Source for
non-technical users
---
surfsense_web/components/citations/run-citation.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/surfsense_web/components/citations/run-citation.tsx b/surfsense_web/components/citations/run-citation.tsx
index ef388373b..d8799a24c 100644
--- a/surfsense_web/components/citations/run-citation.tsx
+++ b/surfsense_web/components/citations/run-citation.tsx
@@ -19,13 +19,13 @@ export const RunCitation: FC<{ runId: string }> = ({ runId }) => {
variant="ghost"
onClick={() => openRunPanel({ runId })}
className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline"
- aria-label="View scraper run"
+ aria-label="See where this came from"
>
- run
+ Source
- View scraper run
+ See where this came from
);
};
From dbedf0cfa53e604e4b9bc3f26f29691b691ddeb4 Mon Sep 17 00:00:00 2001
From: "DESKTOP-RTLN3BA\\$punk"
Date: Wed, 22 Jul 2026 22:16:28 -0700
Subject: [PATCH 150/217] feat(backend): integrate PostHog analytics for
enhanced observability
- Added PostHog configuration options to .env.example files for both Docker and Surfsense backend.
- Introduced PostHog dependency in pyproject.toml.
- Implemented analytics middleware to capture various events across the application, including user authentication, automation runs, and API requests.
- Enhanced existing routes and services to emit analytics events, providing insights into user interactions and system performance.
- Ensured graceful shutdown of analytics clients in worker processes and application lifecycles.
---
docker/.env.example | 9 +
surfsense_backend/.env.example | 8 +
surfsense_backend/app/app.py | 52 +++
.../app/automations/runtime/executor.py | 30 ++
.../app/automations/services/automation.py | 47 +++
.../app/automations/services/trigger.py | 40 ++
.../app/capabilities/core/access/rest.py | 92 ++++-
surfsense_backend/app/celery_app.py | 13 +
surfsense_backend/app/config/__init__.py | 13 +
.../app/observability/__init__.py | 2 +-
.../app/observability/analytics.py | 202 ++++++++++
.../app/podcasts/tasks/render.py | 27 ++
.../app/routes/anonymous_chat_routes.py | 42 +++
.../app/routes/image_generation_routes.py | 15 +
.../app/routes/incentive_tasks_routes.py | 15 +
.../app/routes/new_chat_routes.py | 10 +
.../routes/personal_access_tokens_routes.py | 10 +
.../app/routes/public_chat_routes.py | 16 +-
surfsense_backend/app/routes/rbac_routes.py | 24 ++
.../routes/search_source_connectors_routes.py | 58 +++
surfsense_backend/app/routes/stripe_routes.py | 24 ++
.../app/routes/workspaces_routes.py | 11 +
.../app/tasks/celery_tasks/connector_tasks.py | 83 ++++-
.../app/tasks/celery_tasks/document_tasks.py | 98 ++++-
.../celery_tasks/video_presentation_tasks.py | 15 +
.../streaming/flows/new_chat/orchestrator.py | 42 +++
.../flows/resume_chat/orchestrator.py | 39 ++
.../chat/streaming/flows/shared/analytics.py | 117 ++++++
surfsense_backend/app/users.py | 28 +-
surfsense_backend/pyproject.toml | 1 +
.../unit/observability/test_analytics.py | 149 ++++++++
surfsense_backend/uv.lock | 158 ++++++++
surfsense_mcp/mcp_server/core/client.py | 6 +-
.../app/(home)/login/LocalLoginForm.tsx | 6 +-
surfsense_web/app/(home)/register/page.tsx | 10 +-
.../[workspace_id]/team/team-content.tsx | 23 +-
.../app/invite/[invite_code]/page.tsx | 14 +-
.../automations/automations-mutation.atoms.ts | 73 +---
.../hooks/use-connector-dialog.ts | 28 +-
.../ui/dialogs/CreateWorkspaceDialog.tsx | 4 +-
.../settings/earn-credits-content.tsx | 19 +-
.../components/sources/DocumentUploadTab.tsx | 14 +-
surfsense_web/content/docs/observability.mdx | 34 ++
surfsense_web/hooks/use-run-stream.ts | 4 +-
surfsense_web/lib/apis/base-api.service.ts | 51 ++-
.../lib/chat/stream-engine/engine.ts | 4 +-
surfsense_web/lib/posthog/events.ts | 351 ++----------------
47 files changed, 1618 insertions(+), 513 deletions(-)
create mode 100644 surfsense_backend/app/observability/analytics.py
create mode 100644 surfsense_backend/app/tasks/chat/streaming/flows/shared/analytics.py
create mode 100644 surfsense_backend/tests/unit/observability/test_analytics.py
diff --git a/docker/.env.example b/docker/.env.example
index 78c0bd93a..fb7f5ff42 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -392,6 +392,15 @@ STT_SERVICE=local/base
# OTEL_HTTP_PORT=4318
# OTEL_HEALTH_PORT=13133
+# PostHog product analytics (server-side). Opt-in like OTel: leave
+# POSTHOG_API_KEY unset for zero telemetry. Passed to backend/worker/beat via
+# env_file, so no compose changes are needed. Use the SAME project key as the
+# frontend's NEXT_PUBLIC_POSTHOG_KEY so server events merge onto the persons the
+# web app already identifies by user id.
+# POSTHOG_API_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+# POSTHOG_HOST=https://us.i.posthog.com
+# POSTHOG_AI_PRIVACY_MODE=true # false ships LLM prompt/completion bodies to PostHog
+
# ------------------------------------------------------------------------------
# Advanced (optional)
# ------------------------------------------------------------------------------
diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example
index 266985e6c..1d60c98c3 100644
--- a/surfsense_backend/.env.example
+++ b/surfsense_backend/.env.example
@@ -555,6 +555,14 @@ LANGSMITH_PROJECT=surfsense
# OTEL_METRIC_EXPORT_INTERVAL=300000 # ms; 5 minutes
# OTEL_SDK_DISABLED=true # emergency kill-switch
+# Observability - PostHog product analytics (server-side)
+# Opt-in like OTel: leave POSTHOG_API_KEY unset for zero telemetry. Use the
+# SAME project key as the frontend's NEXT_PUBLIC_POSTHOG_KEY so server events
+# merge onto the persons the web app already identifies by user id.
+# POSTHOG_API_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+# POSTHOG_HOST=https://us.i.posthog.com
+# POSTHOG_AI_PRIVACY_MODE=true # false ships LLM prompt/completion bodies to PostHog
+
# Skills + subagents
# SURFSENSE_ENABLE_SKILLS=false
# SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS=false
diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py
index dde2ce7fa..42efe38a7 100644
--- a/surfsense_backend/app/app.py
+++ b/surfsense_backend/app/app.py
@@ -50,6 +50,7 @@ from app.gateway.inbox_worker import (
start_gateway_inbox_worker,
stop_gateway_inbox_worker,
)
+from app.observability import analytics as ph_analytics
from app.observability import metrics as ot_metrics
from app.observability.bootstrap import init_otel, shutdown_otel
from app.rate_limiter import get_real_client_ip, limiter
@@ -690,6 +691,7 @@ async def lifespan(app: FastAPI):
await stop_gateway_inbox_worker()
_stop_openrouter_background_refresh()
await close_checkpointer()
+ ph_analytics.shutdown()
shutdown_otel()
@@ -796,6 +798,56 @@ class RequestPerfMiddleware(BaseHTTPMiddleware):
app.add_middleware(RequestPerfMiddleware)
+
+# ---------------------------------------------------------------------------
+# PAT / MCP API attribution middleware
+# ---------------------------------------------------------------------------
+# Emits a PostHog ``pat_api_request`` event for any request authenticated by a
+# Personal Access Token, so "documents added via MCP", "searches via MCP" etc.
+# are queryable without instrumenting each route. Relies on ``get_auth_context``
+# stashing the resolved principal on ``request.state.auth_context``; requests
+# that never resolve a PAT principal are silently skipped. No-op when PostHog
+# is unconfigured.
+
+
+class PatApiAnalyticsMiddleware(BaseHTTPMiddleware):
+ """Capture PAT-authenticated API usage (incl. MCP) after each response."""
+
+ async def dispatch(
+ self, request: StarletteRequest, call_next: RequestResponseEndpoint
+ ) -> StarletteResponse:
+ response = await call_next(request)
+ with contextlib.suppress(Exception):
+ ctx = getattr(request.state, "auth_context", None)
+ if (
+ ctx is not None
+ and ctx.method == "pat"
+ and ph_analytics.is_enabled()
+ ):
+ # Use the route *template* (e.g. /documents/{id}) to keep the
+ # ``route`` property low-cardinality; fall back to the raw path.
+ route = request.scope.get("route")
+ route_path = getattr(route, "path", None) or request.url.path
+ client = (
+ "mcp"
+ if request.headers.get("X-SurfSense-Client") == "mcp"
+ else "pat_script"
+ )
+ ph_analytics.capture_for(
+ ctx,
+ "pat_api_request",
+ {
+ "route": route_path,
+ "method": request.method,
+ "status_code": response.status_code,
+ "client": client,
+ },
+ )
+ return response
+
+
+app.add_middleware(PatApiAnalyticsMiddleware)
+
# Add SlowAPI middleware for automatic rate limiting
# Uses Starlette BaseHTTPMiddleware (not the raw ASGI variant) to avoid
# corrupting StreamingResponse — SlowAPIASGIMiddleware re-sends
diff --git a/surfsense_backend/app/automations/runtime/executor.py b/surfsense_backend/app/automations/runtime/executor.py
index d9544cd0c..cbf682d60 100644
--- a/surfsense_backend/app/automations/runtime/executor.py
+++ b/surfsense_backend/app/automations/runtime/executor.py
@@ -15,11 +15,38 @@ from app.automations.schemas.definition.envelope import (
)
from app.automations.schemas.definition.plan_step import PlanStep
from app.automations.templating import build_run_context
+from app.observability import analytics as ph_analytics
from . import repository
from .step import execute_step
+def _capture_run_outcome(run: AutomationRun, status: str) -> None:
+ """Emit ``automation_run_completed`` — headless runs the frontend never sees.
+
+ No-op when PostHog is unconfigured or the owning user is unknown.
+ """
+ automation = run.automation
+ creator_id = getattr(automation, "created_by_user_id", None)
+ if not creator_id:
+ return
+ workspace_id = getattr(automation, "workspace_id", None)
+ ph_analytics.capture(
+ "automation_run_completed",
+ distinct_id=str(creator_id),
+ properties={
+ "automation_id": run.automation_id,
+ "run_id": run.id,
+ "workspace_id": workspace_id,
+ "status": status,
+ "trigger_type": run.trigger.type.value if run.trigger else None,
+ },
+ groups={"workspace": str(workspace_id)}
+ if workspace_id is not None
+ else None,
+ )
+
+
async def execute_run(session: AsyncSession, run_id: int) -> None:
"""Load run ``run_id`` and execute its snapshot plan to a terminal state."""
run = await repository.load_run(session, run_id)
@@ -41,6 +68,7 @@ async def execute_run(session: AsyncSession, run_id: int) -> None:
},
)
await session.commit()
+ _capture_run_outcome(run, "failed")
return
await repository.mark_running(session, run)
@@ -66,6 +94,7 @@ async def execute_run(session: AsyncSession, run_id: int) -> None:
await _run_on_failure(session, run, definition)
await repository.mark_failed(session, run, result.get("error"))
await session.commit()
+ _capture_run_outcome(run, "failed")
return
if result["status"] == "succeeded":
@@ -73,6 +102,7 @@ async def execute_run(session: AsyncSession, run_id: int) -> None:
await repository.mark_succeeded(session, run)
await session.commit()
+ _capture_run_outcome(run, "succeeded")
async def _run_on_failure(
diff --git a/surfsense_backend/app/automations/services/automation.py b/surfsense_backend/app/automations/services/automation.py
index a419327bf..a7bbd3af3 100644
--- a/surfsense_backend/app/automations/services/automation.py
+++ b/surfsense_backend/app/automations/services/automation.py
@@ -29,6 +29,7 @@ from app.automations.services.model_policy import (
from app.automations.triggers import get_trigger
from app.automations.triggers.builtin.schedule import compute_next_fire_at
from app.db import Permission, Workspace, get_async_session
+from app.observability import analytics as ph_analytics
from app.users import get_auth_context
from app.utils.rbac import check_permission
@@ -75,6 +76,18 @@ class AutomationService:
self.session.add(automation)
await self.session.commit()
+
+ # Authoritative creation (migrated from automations-mutation.atoms.ts).
+ ph_analytics.capture_for(
+ self.auth,
+ "automation_created",
+ {
+ "automation_id": automation.id,
+ "workspace_id": automation.workspace_id,
+ "trigger_count": len(payload.triggers),
+ },
+ groups={"workspace": str(automation.workspace_id)},
+ )
return await self._get_with_triggers_or_raise(automation.id)
async def list(
@@ -150,6 +163,31 @@ class AutomationService:
automation.version += 1
await self.session.commit()
+
+ # Migrated from automations-mutation.atoms.ts: a status-only change is a
+ # distinct event; other field edits are ``automation_updated``.
+ if "status" in data:
+ ph_analytics.capture_for(
+ self.auth,
+ "automation_status_changed",
+ {
+ "automation_id": automation.id,
+ "workspace_id": automation.workspace_id,
+ "next_status": str(data["status"]),
+ },
+ groups={"workspace": str(automation.workspace_id)},
+ )
+ if any(k in data for k in ("name", "description", "definition")):
+ ph_analytics.capture_for(
+ self.auth,
+ "automation_updated",
+ {
+ "automation_id": automation.id,
+ "workspace_id": automation.workspace_id,
+ "has_definition_change": "definition" in data,
+ },
+ groups={"workspace": str(automation.workspace_id)},
+ )
return await self._get_with_triggers_or_raise(automation_id)
async def delete(self, automation_id: int) -> None:
@@ -158,9 +196,18 @@ class AutomationService:
await self._authorize(
automation.workspace_id, Permission.AUTOMATIONS_DELETE.value
)
+ workspace_id = automation.workspace_id
await self.session.delete(automation)
await self.session.commit()
+ # Authoritative deletion (migrated from automations-mutation.atoms.ts).
+ ph_analytics.capture_for(
+ self.auth,
+ "automation_deleted",
+ {"automation_id": automation_id, "workspace_id": workspace_id},
+ groups={"workspace": str(workspace_id)},
+ )
+
async def _get_or_raise(self, automation_id: int) -> Automation:
automation = await self.session.get(Automation, automation_id)
if automation is None:
diff --git a/surfsense_backend/app/automations/services/trigger.py b/surfsense_backend/app/automations/services/trigger.py
index 5175eb15e..866e66395 100644
--- a/surfsense_backend/app/automations/services/trigger.py
+++ b/surfsense_backend/app/automations/services/trigger.py
@@ -16,6 +16,7 @@ from app.automations.schemas.api import TriggerCreate, TriggerUpdate
from app.automations.triggers import get_trigger
from app.automations.triggers.builtin.schedule import compute_next_fire_at
from app.db import Permission, get_async_session
+from app.observability import analytics as ph_analytics
from app.users import get_auth_context
from app.utils.rbac import check_permission
@@ -48,6 +49,18 @@ class TriggerService:
self.session.add(trigger)
await self.session.commit()
await self.session.refresh(trigger)
+
+ # Migrated from automations-mutation.atoms.ts.
+ ph_analytics.capture_for(
+ self.auth,
+ "automation_trigger_added",
+ {
+ "automation_id": automation_id,
+ "trigger_id": trigger.id,
+ "trigger_type": getattr(trigger.type, "value", str(trigger.type)),
+ "enabled": trigger.enabled,
+ },
+ )
return trigger
async def update(
@@ -82,6 +95,26 @@ class TriggerService:
await self.session.commit()
await self.session.refresh(trigger)
+
+ # Migrated from automations-mutation.atoms.ts. ``change`` mirrors the
+ # frontend's coarse categorisation.
+ _change = (
+ "enabled"
+ if "enabled" in data and "params" not in data
+ else "params"
+ if "params" in data
+ else "other"
+ )
+ ph_analytics.capture_for(
+ self.auth,
+ "automation_trigger_updated",
+ {
+ "automation_id": automation_id,
+ "trigger_id": trigger_id,
+ "change": _change,
+ "enabled": trigger.enabled,
+ },
+ )
return trigger
async def remove(self, *, automation_id: int, trigger_id: int) -> None:
@@ -92,6 +125,13 @@ class TriggerService:
await self.session.delete(trigger)
await self.session.commit()
+ # Migrated from automations-mutation.atoms.ts.
+ ph_analytics.capture_for(
+ self.auth,
+ "automation_trigger_removed",
+ {"automation_id": automation_id, "trigger_id": trigger_id},
+ )
+
async def _authorize_automation(
self, automation_id: int, permission: str
) -> Automation:
diff --git a/surfsense_backend/app/capabilities/core/access/rest.py b/surfsense_backend/app/capabilities/core/access/rest.py
index 2047b21ae..26ac46318 100644
--- a/surfsense_backend/app/capabilities/core/access/rest.py
+++ b/surfsense_backend/app/capabilities/core/access/rest.py
@@ -45,6 +45,7 @@ from app.capabilities.core.store import all_capabilities
from app.capabilities.core.types import Capability, CapabilityContext
from app.db import Run, async_session_maker, get_async_session
from app.exceptions import ExternalServiceError, SurfSenseError
+from app.observability import analytics as ph_analytics
from app.services.web_crawl_credit_service import InsufficientCreditsError
from app.users import get_auth_context
from app.utils.rbac import check_workspace_access
@@ -107,6 +108,41 @@ def _origin_for(auth: AuthContext) -> str:
return "ui" if getattr(auth, "method", None) == "session" else "api"
+def _capture_scraper_run(
+ *,
+ capability: str,
+ status: str,
+ user_id,
+ origin: str,
+ duration_ms: int | None = None,
+ item_count: int | None = None,
+ cost_micros: int | None = None,
+) -> None:
+ """Emit ``scraper_run_completed`` — scrapers are the highest-value MCP surface.
+
+ Covers both sync and async runs; the async background task never flows
+ through the PAT middleware, so this is the only place its outcome is
+ captured. No-op when PostHog is unconfigured.
+ """
+ if not ph_analytics.is_enabled() or not user_id:
+ return
+ platform, _, verb = capability.partition(".")
+ ph_analytics.capture(
+ "scraper_run_completed",
+ distinct_id=str(user_id),
+ properties={
+ "platform": platform,
+ "verb": verb,
+ "capability": capability,
+ "status": status,
+ "origin": origin,
+ "duration_ms": duration_ms,
+ "item_count": item_count,
+ "cost_micros": cost_micros,
+ },
+ )
+
+
def _now_ms() -> int:
return int(time.time() * 1000)
@@ -185,6 +221,8 @@ async def _execute_async_run(
unit,
executor,
payload,
+ user_id=None,
+ origin: str = "api",
) -> None:
"""Run a scrape in the background: stream progress, charge, finalize the row.
@@ -218,6 +256,13 @@ async def _execute_async_run(
progress=reporter.coarse,
)
_publish_finished(run_id, "error", error=str(exc))
+ _capture_scraper_run(
+ capability=capability,
+ status="error",
+ user_id=user_id,
+ origin=origin,
+ duration_ms=int((time.perf_counter() - started) * 1000),
+ )
return
except Exception:
logger.exception("async run %s failed with an upstream error", run_id)
@@ -229,6 +274,13 @@ async def _execute_async_run(
progress=reporter.coarse,
)
_publish_finished(run_id, "error", error="upstream error")
+ _capture_scraper_run(
+ capability=capability,
+ status="error",
+ user_id=user_id,
+ origin=origin,
+ duration_ms=int((time.perf_counter() - started) * 1000),
+ )
return
duration_ms = int((time.perf_counter() - started) * 1000)
@@ -252,6 +304,15 @@ async def _execute_async_run(
progress=reporter.coarse,
)
_publish_finished(run_id, "success", item_count=serialized.item_count)
+ _capture_scraper_run(
+ capability=capability,
+ status="success",
+ user_id=user_id,
+ origin=origin,
+ duration_ms=duration_ms,
+ item_count=serialized.item_count,
+ cost_micros=cost_micros,
+ )
async def _finalize_async(
@@ -358,6 +419,8 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
unit=unit,
executor=executor,
payload=payload,
+ user_id=user_id,
+ origin=origin,
)
)
run_event_bus.register_task(run_id, task)
@@ -373,6 +436,7 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
try:
output = await executor(payload)
except (SurfSenseError, HTTPException) as exc:
+ _sync_err_duration = int((time.perf_counter() - started) * 1000)
await _record_rest_run(
workspace_id=workspace_id,
capability=name,
@@ -381,11 +445,19 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
input=input_dump,
user_id=user_id,
error=str(exc),
- duration_ms=int((time.perf_counter() - started) * 1000),
+ duration_ms=_sync_err_duration,
progress=reporter.coarse,
)
+ _capture_scraper_run(
+ capability=name,
+ status="error",
+ user_id=user_id,
+ origin=origin,
+ duration_ms=_sync_err_duration,
+ )
raise
except Exception as exc:
+ _sync_err_duration = int((time.perf_counter() - started) * 1000)
await _record_rest_run(
workspace_id=workspace_id,
capability=name,
@@ -394,9 +466,16 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
input=input_dump,
user_id=user_id,
error=str(exc),
- duration_ms=int((time.perf_counter() - started) * 1000),
+ duration_ms=_sync_err_duration,
progress=reporter.coarse,
)
+ _capture_scraper_run(
+ capability=name,
+ status="error",
+ user_id=user_id,
+ origin=origin,
+ duration_ms=_sync_err_duration,
+ )
raise ExternalServiceError(
f"The '{name}' capability failed due to an upstream error.",
code="CAPABILITY_UPSTREAM_ERROR",
@@ -418,6 +497,15 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
cost_micros=cost_micros,
progress=reporter.coarse,
)
+ _capture_scraper_run(
+ capability=name,
+ status="success",
+ user_id=user_id,
+ origin=origin,
+ duration_ms=duration_ms,
+ item_count=serialized.item_count,
+ cost_micros=cost_micros,
+ )
if run_id is not None:
response.headers["X-Run-Id"] = f"run_{run_id}"
return output
diff --git a/surfsense_backend/app/celery_app.py b/surfsense_backend/app/celery_app.py
index 471d80197..2a9cddbd5 100644
--- a/surfsense_backend/app/celery_app.py
+++ b/surfsense_backend/app/celery_app.py
@@ -10,6 +10,7 @@ from celery.signals import (
task_postrun,
task_prerun,
worker_process_init,
+ worker_process_shutdown,
)
from dotenv import load_dotenv
@@ -123,6 +124,18 @@ def init_worker(**kwargs):
initialize_image_gen_router()
+@worker_process_shutdown.connect
+def shutdown_worker(**kwargs):
+ """Flush queued PostHog events before a Celery worker process exits.
+
+ The analytics client init is lazy (fork-safe), so there is nothing to
+ start here — only a flush to avoid dropping events captured by tasks.
+ """
+ from app.observability import analytics as ph_analytics
+
+ ph_analytics.shutdown()
+
+
# Celery configuration, sourced from the central Config singleton
CELERY_BROKER_URL = config.CELERY_BROKER_URL
CELERY_RESULT_BACKEND = config.CELERY_RESULT_BACKEND
diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py
index 73062775c..45ef065df 100644
--- a/surfsense_backend/app/config/__init__.py
+++ b/surfsense_backend/app/config/__init__.py
@@ -1179,6 +1179,19 @@ class Config:
os.getenv("CRAWL_HEADED_XVFB_ENABLED", "FALSE").upper() == "TRUE"
)
+ # PostHog server-side product analytics (opt-in, mirrors the OTel pattern:
+ # no key set => the analytics wrapper is a silent no-op). Use the SAME
+ # project key as the frontend's NEXT_PUBLIC_POSTHOG_KEY so server events
+ # merge onto the persons the web app already identifies by user id.
+ POSTHOG_API_KEY = os.getenv("POSTHOG_API_KEY")
+ POSTHOG_HOST = os.getenv("POSTHOG_HOST", "https://us.i.posthog.com")
+ # When true (default), the LLM-analytics LangChain handler suppresses
+ # prompt/completion bodies ($ai_input / $ai_output_choices) and captures
+ # only metrics — chat content includes users' private documents.
+ POSTHOG_AI_PRIVACY_MODE = (
+ os.getenv("POSTHOG_AI_PRIVACY_MODE", "TRUE").upper() == "TRUE"
+ )
+
# Litellm TTS Configuration
TTS_SERVICE = os.getenv("TTS_SERVICE")
TTS_SERVICE_API_BASE = os.getenv("TTS_SERVICE_API_BASE")
diff --git a/surfsense_backend/app/observability/__init__.py b/surfsense_backend/app/observability/__init__.py
index a675b1dae..4ec9f1b03 100644
--- a/surfsense_backend/app/observability/__init__.py
+++ b/surfsense_backend/app/observability/__init__.py
@@ -6,4 +6,4 @@ wrapper is a no-op when OTEL is not configured, so importing it from
performance-critical paths is safe.
"""
-__all__ = ["bootstrap", "metrics", "otel"]
+__all__ = ["analytics", "bootstrap", "metrics", "otel"]
diff --git a/surfsense_backend/app/observability/analytics.py b/surfsense_backend/app/observability/analytics.py
new file mode 100644
index 000000000..312335d24
--- /dev/null
+++ b/surfsense_backend/app/observability/analytics.py
@@ -0,0 +1,202 @@
+"""Server-side PostHog product analytics for SurfSense.
+
+Opt-in, mirroring the OpenTelemetry bootstrap contract: when
+``POSTHOG_API_KEY`` is unset every function here is a silent no-op, so it is
+safe to call from hot paths (including async request handlers) and from
+self-hosted installs that never configure telemetry.
+
+Design notes:
+- The underlying ``posthog`` client enqueues events onto a background
+ consumer thread, so ``capture()`` is a non-blocking queue append; the only
+ network I/O happens off-thread. ``shutdown()`` flushes and joins that thread
+ and MUST run before a process exits or queued events are lost.
+- The client is created lazily on first use, never at import time. This keeps
+ it fork-safe under Celery's prefork pool: a client (and its consumer thread)
+ created in the parent would not survive ``fork()``, so each worker process
+ builds its own on first capture.
+- ``distinct_id`` is always ``str(user.id)`` so server events merge onto the
+ same PostHog persons the web frontend identifies (see
+ ``surfsense_web/components/providers/PostHogIdentify.tsx``).
+- Every event passes ``disable_geoip=True``; without it PostHog would resolve
+ the *server's* IP and overwrite each person's real (client-derived) location.
+"""
+
+from __future__ import annotations
+
+import logging
+import threading
+from typing import TYPE_CHECKING, Any
+
+from app.config import config
+
+if TYPE_CHECKING:
+ from app.auth.context import AuthContext
+
+logger = logging.getLogger(__name__)
+
+_client: Any | None = None
+_init_attempted = False
+_lock = threading.Lock()
+
+# Stamped on every backend event so client-observed (frontend) and
+# server-truth events are always distinguishable in PostHog.
+_SOURCE = "backend"
+
+
+def _get_client() -> Any | None:
+ """Return the process-local PostHog client, or ``None`` when disabled.
+
+ Lazy + fork-safe: built on first use inside whichever process (web worker
+ or Celery worker) calls it, never at import time.
+ """
+ global _client, _init_attempted
+
+ if _init_attempted:
+ return _client
+
+ with _lock:
+ if _init_attempted:
+ return _client
+ _init_attempted = True
+
+ api_key = config.POSTHOG_API_KEY
+ if not api_key:
+ # ponytail: opt-in like OTel — no key means telemetry is off, not
+ # a misconfiguration. Stay silent so self-hosters see no noise.
+ return None
+
+ try:
+ from posthog import Posthog
+
+ _client = Posthog(
+ project_api_key=api_key,
+ host=config.POSTHOG_HOST,
+ )
+ except Exception:
+ logger.warning("PostHog analytics init failed; disabling", exc_info=True)
+ _client = None
+
+ return _client
+
+
+def is_enabled() -> bool:
+ """True when a PostHog client is configured and available."""
+ return _get_client() is not None
+
+
+def get_client() -> Any | None:
+ """Raw PostHog client for integrations that need it (e.g. the LLM handler)."""
+ return _get_client()
+
+
+def _client_label(auth: AuthContext) -> str:
+ """Best-effort ``client`` property derived from the auth principal.
+
+ ``session`` can't be split into web vs desktop from auth alone, so callers
+ that know better may override ``client`` in ``properties``.
+ """
+ if auth.method == "system":
+ return auth.source or "system"
+ if auth.method == "pat":
+ return "pat"
+ return "web"
+
+
+def capture(
+ event: str,
+ *,
+ distinct_id: str,
+ properties: dict[str, Any] | None = None,
+ groups: dict[str, str] | None = None,
+) -> None:
+ """Capture a product event. No-op (and never raises) when disabled.
+
+ Wrapped in try/except like the frontend ``safeCapture`` — analytics must
+ never break a request. ``posthog`` v6 signature is ``capture(event,
+ distinct_id=..., properties=...)`` (event first, distinct_id a kwarg).
+ """
+ client = _get_client()
+ if client is None:
+ return
+
+ try:
+ props = {"source": _SOURCE, **(properties or {})}
+ client.capture(
+ event,
+ distinct_id=distinct_id,
+ properties=props,
+ groups=groups,
+ disable_geoip=True,
+ )
+ except Exception:
+ logger.debug("PostHog capture failed for %s", event, exc_info=True)
+
+
+def capture_for(
+ auth: AuthContext,
+ event: str,
+ properties: dict[str, Any] | None = None,
+ groups: dict[str, str] | None = None,
+) -> None:
+ """Capture an event attributed to an ``AuthContext`` principal.
+
+ Derives ``distinct_id`` from the user id and stamps ``auth_method`` and a
+ best-effort ``client`` so events are attributable to their surface
+ (web/desktop/pat/gateway/automation).
+ """
+ if _get_client() is None:
+ return
+
+ props = {
+ "auth_method": auth.method,
+ "client": _client_label(auth),
+ **(properties or {}),
+ }
+ capture(
+ event,
+ distinct_id=str(auth.user.id),
+ properties=props,
+ groups=groups,
+ )
+
+
+def group_identify(
+ group_type: str,
+ group_key: str,
+ properties: dict[str, Any] | None = None,
+) -> None:
+ """Upsert group properties (e.g. per-workspace metadata). No-op when disabled."""
+ client = _get_client()
+ if client is None:
+ return
+
+ try:
+ client.group_identify(
+ group_type=group_type,
+ group_key=group_key,
+ properties=properties or {},
+ )
+ except Exception:
+ logger.debug("PostHog group_identify failed for %s", group_type, exc_info=True)
+
+
+def shutdown() -> None:
+ """Flush queued events and stop the consumer thread. Safe to call always."""
+ global _client
+ client = _client
+ if client is None:
+ return
+ try:
+ client.shutdown()
+ except Exception:
+ logger.debug("PostHog shutdown failed", exc_info=True)
+
+
+__all__ = [
+ "capture",
+ "capture_for",
+ "get_client",
+ "group_identify",
+ "is_enabled",
+ "shutdown",
+]
diff --git a/surfsense_backend/app/podcasts/tasks/render.py b/surfsense_backend/app/podcasts/tasks/render.py
index 7759691a9..0cd6fbe5a 100644
--- a/surfsense_backend/app/podcasts/tasks/render.py
+++ b/surfsense_backend/app/podcasts/tasks/render.py
@@ -11,7 +11,10 @@ import logging
import tempfile
from pathlib import Path
+from sqlalchemy import select
+
from app.celery_app import celery_app
+from app.observability import analytics as ph_analytics
from app.podcasts.persistence import PodcastRepository
from app.podcasts.rendering import PodcastRenderer
from app.podcasts.service import (
@@ -76,6 +79,30 @@ async def _render_audio(podcast_id: int) -> dict:
podcast, storage_backend=backend_name, storage_key=key
)
await session.commit()
+
+ # Credit-consuming deliverable; the frontend never confirms the
+ # render finished. Owner (workspace.user_id) resolved lazily so
+ # disabled installs pay nothing for the extra query.
+ if ph_analytics.is_enabled():
+ # Local import: app.db <-> app.podcasts.persistence have a
+ # module-init cycle; deferring keeps this task importable.
+ from app.db import Workspace
+
+ owner_id = await session.scalar(
+ select(Workspace.user_id).where(
+ Workspace.id == podcast.workspace_id
+ )
+ )
+ if owner_id:
+ ph_analytics.capture(
+ "podcast_generated",
+ distinct_id=str(owner_id),
+ properties={
+ "workspace_id": podcast.workspace_id,
+ "podcast_id": podcast_id,
+ },
+ groups={"workspace": str(podcast.workspace_id)},
+ )
except InvalidTransitionError:
# A user back-out won the race (e.g. the regeneration was
# reverted): drop the stale render and leave the row alone.
diff --git a/surfsense_backend/app/routes/anonymous_chat_routes.py b/surfsense_backend/app/routes/anonymous_chat_routes.py
index 3ff2fd38a..f53217826 100644
--- a/surfsense_backend/app/routes/anonymous_chat_routes.py
+++ b/surfsense_backend/app/routes/anonymous_chat_routes.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import contextlib
import logging
import secrets
import uuid
@@ -13,6 +14,10 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.config import config
+from app.observability import analytics as ph_analytics
+from app.tasks.chat.streaming.flows.shared.analytics import (
+ build_llm_callback_handler,
+)
from app.etl_pipeline.file_classifier import (
DIRECT_CONVERT_EXTENSIONS,
PLAINTEXT_EXTENSIONS,
@@ -354,6 +359,7 @@ async def stream_anonymous_chat(
accumulator = start_turn()
streaming_service = VercelStreamingService()
+ anon_outcome = "success"
try:
async with shielded_async_session():
@@ -394,6 +400,21 @@ async def stream_anonymous_chat(
"recursion_limit": 40,
}
+ # PostHog LLM analytics for the free tier — model spend per
+ # model is the highest-value cost insight. distinct_id is the
+ # anon session id (not joined to any registered person).
+ _anon_llm_handler = build_llm_callback_handler(
+ distinct_id=session_id,
+ trace_id=anon_thread_id,
+ properties={
+ "client": "anonymous",
+ "model_slug": body.model_slug,
+ "$ai_session_id": session_id,
+ },
+ )
+ if _anon_llm_handler is not None:
+ langgraph_config["callbacks"] = [_anon_llm_handler]
+
yield streaming_service.format_message_start()
yield streaming_service.format_start_step()
@@ -465,6 +486,7 @@ async def stream_anonymous_chat(
except Exception as e:
logger.exception("Anonymous chat stream error")
+ anon_outcome = "error"
await TokenQuotaService.anon_release(session_key, ip_key, request_id)
_, error_code, _, _, user_message, extra = classify_stream_exception(
e,
@@ -479,6 +501,26 @@ async def stream_anonymous_chat(
finally:
await TokenQuotaService.anon_release_stream_slot(client_ip)
+ # Server-truth free-tier volume/model/outcome/token-burn. distinct_id
+ # is the anon session id — deliberately NOT joined to the frontend's
+ # PostHog anonymous id (the point is server truth, not funnel merge).
+ with contextlib.suppress(Exception):
+ if ph_analytics.is_enabled():
+ ph_analytics.capture(
+ "anon_chat_turn_completed",
+ distinct_id=session_id,
+ properties={
+ "client": "anonymous",
+ "outcome": anon_outcome,
+ "model_slug": body.model_slug,
+ "model_name": model_cfg.get("model_name"),
+ "total_tokens": accumulator.grand_total,
+ "prompt_tokens": accumulator.total_prompt_tokens,
+ "completion_tokens": accumulator.total_completion_tokens,
+ "cost_micros": accumulator.total_cost_micros,
+ },
+ )
+
return StreamingResponse(
_generate(),
media_type="text/event-stream",
diff --git a/surfsense_backend/app/routes/image_generation_routes.py b/surfsense_backend/app/routes/image_generation_routes.py
index 5a6c71127..e4aab3f80 100644
--- a/surfsense_backend/app/routes/image_generation_routes.py
+++ b/surfsense_backend/app/routes/image_generation_routes.py
@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.auth.context import AuthContext
+from app.observability import analytics as ph_analytics
from app.config import config
from app.db import (
ImageGeneration,
@@ -327,6 +328,20 @@ async def create_image_generation(
await session.commit()
await session.refresh(db_image_gen)
+
+ # Credit-consuming deliverable; the frontend never confirms
+ # completion. ``error_message`` set => provider call failed.
+ ph_analytics.capture_for(
+ auth,
+ "image_generated",
+ {
+ "workspace_id": data.workspace_id,
+ "model": data.model,
+ "n": data.n,
+ "status": "error" if db_image_gen.error_message else "success",
+ },
+ groups={"workspace": str(data.workspace_id)},
+ )
return db_image_gen
except HTTPException:
diff --git a/surfsense_backend/app/routes/incentive_tasks_routes.py b/surfsense_backend/app/routes/incentive_tasks_routes.py
index 2635df42f..a4c553097 100644
--- a/surfsense_backend/app/routes/incentive_tasks_routes.py
+++ b/surfsense_backend/app/routes/incentive_tasks_routes.py
@@ -15,6 +15,7 @@ from app.db import (
UserIncentiveTask,
get_async_session,
)
+from app.observability import analytics as ph_analytics
from app.schemas.incentive_tasks import (
CompleteTaskResponse,
IncentiveTaskInfo,
@@ -125,6 +126,20 @@ async def complete_task(
await session.commit()
await session.refresh(user)
+ # Authoritative reward grant (migrated from earn-credits-content.tsx).
+ # Placed after the already-completed early-return so retries never
+ # double-count.
+ ph_analytics.capture_for(
+ auth,
+ "incentive_task_completed",
+ {
+ "task_type": task_type.value
+ if hasattr(task_type, "value")
+ else str(task_type),
+ "credit_micros_rewarded": credit_micros_reward,
+ },
+ )
+
return CompleteTaskResponse(
success=True,
message=f"Task completed! You earned ${credit_micros_reward / 1_000_000:.2f} of credit.",
diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py
index 89d1cef95..71a70e9b5 100644
--- a/surfsense_backend/app/routes/new_chat_routes.py
+++ b/surfsense_backend/app/routes/new_chat_routes.py
@@ -51,6 +51,7 @@ from app.db import (
get_async_session,
shielded_async_session,
)
+from app.observability import analytics as ph_analytics
from app.schemas.new_chat import (
AgentToolInfo,
CancelActiveTurnResponse,
@@ -823,6 +824,15 @@ async def create_thread(
session.add(db_thread)
await session.commit()
await session.refresh(db_thread)
+
+ # Authoritative thread creation (migrated from stream-engine/engine.ts).
+ # get_auth_context => covers PAT/MCP callers the frontend never sees.
+ ph_analytics.capture_for(
+ auth,
+ "chat_created",
+ {"workspace_id": db_thread.workspace_id, "chat_id": db_thread.id},
+ groups={"workspace": str(db_thread.workspace_id)},
+ )
return db_thread
except HTTPException:
diff --git a/surfsense_backend/app/routes/personal_access_tokens_routes.py b/surfsense_backend/app/routes/personal_access_tokens_routes.py
index a7849a2fc..3d63703b4 100644
--- a/surfsense_backend/app/routes/personal_access_tokens_routes.py
+++ b/surfsense_backend/app/routes/personal_access_tokens_routes.py
@@ -8,6 +8,7 @@ from sqlalchemy.future import select
from app.auth.context import AuthContext
from app.config import config
from app.db import PersonalAccessToken, get_async_session
+from app.observability import analytics as ph_analytics
from app.schemas.pat import PATCreate, PATCreated, PATRead
from app.users import require_session_context
from app.utils.pat import generate_pat, hash_pat, token_prefix
@@ -57,6 +58,13 @@ async def create_personal_access_token(
await session.commit()
await session.refresh(pat)
+ # Leading indicator of MCP / programmatic-API adoption.
+ ph_analytics.capture_for(
+ auth,
+ "pat_created",
+ {"pat_id": pat.id, "has_expiry": pat.expires_at is not None},
+ )
+
return PATCreated(
id=pat.id,
label=pat.label,
@@ -102,3 +110,5 @@ async def delete_personal_access_token(
)
)
await session.commit()
+
+ ph_analytics.capture_for(auth, "pat_revoked", {"pat_id": pat_id})
diff --git a/surfsense_backend/app/routes/public_chat_routes.py b/surfsense_backend/app/routes/public_chat_routes.py
index 70f012911..f90050055 100644
--- a/surfsense_backend/app/routes/public_chat_routes.py
+++ b/surfsense_backend/app/routes/public_chat_routes.py
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext
from app.db import get_async_session
+from app.observability import analytics as ph_analytics
from app.schemas.new_chat import (
CloneResponse,
PublicChatResponse,
@@ -56,7 +57,20 @@ async def clone_public_chat(
Creates thread and copies messages.
Requires authentication.
"""
- return await clone_from_snapshot(session, share_token, user)
+ result = await clone_from_snapshot(session, share_token, user)
+
+ # Share-link conversion — only observable server-side.
+ ph_analytics.capture_for(
+ auth,
+ "public_chat_cloned",
+ {
+ "workspace_id": result.workspace_id,
+ "chat_id": result.thread_id,
+ },
+ groups={"workspace": str(result.workspace_id)},
+ )
+
+ return result
@router.get("/{share_token}/podcasts/{podcast_id}")
diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py
index 2d8f58d33..8ab0e17c6 100644
--- a/surfsense_backend/app/routes/rbac_routes.py
+++ b/surfsense_backend/app/routes/rbac_routes.py
@@ -28,6 +28,7 @@ from app.db import (
WorkspaceRole,
get_async_session,
)
+from app.observability import analytics as ph_analytics
from app.schemas import (
InviteAcceptRequest,
InviteAcceptResponse,
@@ -782,6 +783,19 @@ async def create_invite(
)
db_invite = result.scalars().first()
+ # Authoritative invite creation (migrated from team-content.tsx).
+ ph_analytics.capture_for(
+ auth,
+ "workspace_invite_sent",
+ {
+ "workspace_id": workspace_id,
+ "role_name": db_invite.role.name if db_invite.role else None,
+ "has_expiry": db_invite.expires_at is not None,
+ "has_max_uses": db_invite.max_uses is not None,
+ },
+ groups={"workspace": str(workspace_id)},
+ )
+
return db_invite
except HTTPException:
@@ -1091,6 +1105,16 @@ async def accept_invite(
role_name = invite.role.name if invite.role else "Default"
workspace_name = invite.workspace.name if invite.workspace else ""
+ # Authoritative join (migrated from app/invite/[invite_code]/page.tsx,
+ # which fired both events). workspace_name dropped — user content.
+ for _evt in ("workspace_invite_accepted", "workspace_user_added"):
+ ph_analytics.capture_for(
+ auth,
+ _evt,
+ {"workspace_id": invite.workspace_id, "role_name": role_name},
+ groups={"workspace": str(invite.workspace_id)},
+ )
+
return InviteAcceptResponse(
message="Successfully joined the workspace",
workspace_id=invite.workspace_id,
diff --git a/surfsense_backend/app/routes/search_source_connectors_routes.py b/surfsense_backend/app/routes/search_source_connectors_routes.py
index 4cfa78af2..ba0899db3 100644
--- a/surfsense_backend/app/routes/search_source_connectors_routes.py
+++ b/surfsense_backend/app/routes/search_source_connectors_routes.py
@@ -29,6 +29,7 @@ import redis
from dateutil.parser import isoparse
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel, Field, ValidationError
+from sqlalchemy import event as sa_event
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
@@ -44,6 +45,7 @@ from app.db import (
get_async_session,
)
from app.notifications.service import NotificationService
+from app.observability import analytics as ph_analytics
from app.observability import metrics as ot_metrics, otel as ot
from app.schemas import (
GoogleDriveIndexRequest,
@@ -135,6 +137,45 @@ def _get_heartbeat_key(notification_id: int) -> str:
router = APIRouter()
+def _connector_type_value(connector_type) -> str:
+ """Low-cardinality connector_type string for analytics (enum -> its value)."""
+ return getattr(connector_type, "value", None) or str(connector_type)
+
+
+def _emit_connector_connected(_mapper, _connection, target) -> None:
+ """SQLAlchemy after_insert hook: one guard for every connector-creation path.
+
+ Fires ``connector_connected`` for the form route here AND all 15 OAuth
+ callback routes (each builds ``SearchSourceConnector(...)`` inline with no
+ shared helper), so we don't instrument 16 call sites. ``distinct_id`` comes
+ off the row itself (``user_id``) — the same person the web app identifies.
+
+ ponytail: fires during flush, so a post-flush rollback would over-count
+ (rare; connector creation commits immediately after add). No-op without a
+ PostHog key. Upgrade path: move to an after_commit collector if over-count
+ ever shows up in the data.
+ """
+ if not ph_analytics.is_enabled():
+ return
+ user_id = getattr(target, "user_id", None)
+ if not user_id:
+ return
+ workspace_id = getattr(target, "workspace_id", None)
+ ph_analytics.capture(
+ "connector_connected",
+ distinct_id=str(user_id),
+ properties={
+ "workspace_id": workspace_id,
+ "connector_id": target.id,
+ "connector_type": _connector_type_value(target.connector_type),
+ },
+ groups={"workspace": str(workspace_id)} if workspace_id is not None else None,
+ )
+
+
+sa_event.listen(SearchSourceConnector, "after_insert", _emit_connector_connected)
+
+
# Use Pydantic's BaseModel here
class GitHubPATRequest(BaseModel):
github_pat: str = Field(..., description="GitHub Personal Access Token")
@@ -244,6 +285,10 @@ async def create_search_source_connector(
await session.commit()
await session.refresh(db_connector)
+ # ``connector_connected`` is emitted by the after_insert listener below,
+ # so it fires once for this form route AND all 15 OAuth callback routes
+ # without instrumenting each — see _emit_connector_connected.
+
# Create periodic schedule if periodic indexing is enabled
if (
db_connector.periodic_indexing_enabled
@@ -679,10 +724,23 @@ async def delete_search_source_connector(
# Delete the connector record
workspace_id = db_connector.workspace_id
+ deleted_connector_type = db_connector.connector_type
is_mcp = db_connector.connector_type == SearchSourceConnectorType.MCP_CONNECTOR
await session.delete(db_connector)
await session.commit()
+ # Authoritative deletion (migrated from use-connector-dialog.ts).
+ ph_analytics.capture_for(
+ auth,
+ "connector_deleted",
+ {
+ "workspace_id": workspace_id,
+ "connector_id": connector_id,
+ "connector_type": _connector_type_value(deleted_connector_type),
+ },
+ groups={"workspace": str(workspace_id)},
+ )
+
if is_mcp:
from app.agents.chat.multi_agent_chat.shared.tools.mcp.tool import (
invalidate_mcp_tools_cache,
diff --git a/surfsense_backend/app/routes/stripe_routes.py b/surfsense_backend/app/routes/stripe_routes.py
index 459b23d60..4da24c2af 100644
--- a/surfsense_backend/app/routes/stripe_routes.py
+++ b/surfsense_backend/app/routes/stripe_routes.py
@@ -27,6 +27,7 @@ from app.db import (
User,
get_async_session,
)
+from app.observability import analytics as ph_analytics
from app.schemas.stripe import (
AutoReloadSettingsResponse,
CreateAutoReloadSetupSessionRequest,
@@ -47,6 +48,26 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/stripe", tags=["stripe"])
+def _capture_credits_purchased(user: User, purchase: CreditPurchase) -> None:
+ """Emit ``credits_purchased`` — revenue events only exist server-side.
+
+ Call only from the idempotent grant paths (which early-return on already
+ COMPLETED / non-PENDING rows) so Stripe retries never double-count. No-op
+ when PostHog is unconfigured.
+ """
+ ph_analytics.capture(
+ "credits_purchased",
+ distinct_id=str(user.id),
+ properties={
+ "credit_micros_granted": purchase.credit_micros_granted,
+ "quantity": purchase.quantity,
+ "amount_total": purchase.amount_total,
+ "currency": purchase.currency,
+ "source": purchase.source,
+ },
+ )
+
+
def get_stripe_client() -> StripeClient:
"""Return a configured Stripe client or raise if Stripe is disabled."""
if not config.STRIPE_SECRET_KEY:
@@ -309,6 +330,7 @@ async def _fulfill_completed_credit_purchase(
)
await db_session.commit()
+ _capture_credits_purchased(user, purchase)
return StripeWebhookResponse()
@@ -448,6 +470,8 @@ async def _reconcile_auto_reload_payment_intent(
purchase.status = CreditPurchaseStatus.FAILED
await db_session.commit()
+ if succeeded:
+ _capture_credits_purchased(user, purchase)
return StripeWebhookResponse()
diff --git a/surfsense_backend/app/routes/workspaces_routes.py b/surfsense_backend/app/routes/workspaces_routes.py
index 53d4344f7..26e08a225 100644
--- a/surfsense_backend/app/routes/workspaces_routes.py
+++ b/surfsense_backend/app/routes/workspaces_routes.py
@@ -15,6 +15,7 @@ from app.db import (
get_async_session,
get_default_roles_config,
)
+from app.observability import analytics as ph_analytics
from app.routes.model_connections_routes import compute_llm_setup_status
from app.schemas import (
WorkspaceApiAccessUpdate,
@@ -112,6 +113,16 @@ async def create_workspace(
await session.commit()
await session.refresh(db_workspace)
+ # Authoritative creation event (migrated from the frontend
+ # CreateWorkspaceDialog). Workspace name is intentionally NOT sent —
+ # it's user content with no aggregation value.
+ ph_analytics.capture_for(
+ auth,
+ "workspace_created",
+ {"workspace_id": db_workspace.id},
+ groups={"workspace": str(db_workspace.id)},
+ )
+
response = WorkspaceRead.model_validate(db_workspace)
response.llm_setup = await compute_llm_setup_status(
session, auth, db_workspace.id
diff --git a/surfsense_backend/app/tasks/celery_tasks/connector_tasks.py b/surfsense_backend/app/tasks/celery_tasks/connector_tasks.py
index ec078e031..fb08215b5 100644
--- a/surfsense_backend/app/tasks/celery_tasks/connector_tasks.py
+++ b/surfsense_backend/app/tasks/celery_tasks/connector_tasks.py
@@ -1,5 +1,6 @@
"""Celery tasks for connector indexing."""
+import contextlib
import logging
import time
import traceback
@@ -8,6 +9,7 @@ from collections.abc import Awaitable, Callable
from celery import current_task
from app.celery_app import celery_app
+from app.observability import analytics as ph_analytics
from app.observability import metrics as ot_metrics, otel as ot
from app.tasks.celery_tasks import (
get_celery_session_maker,
@@ -17,8 +19,18 @@ from app.tasks.celery_tasks import (
logger = logging.getLogger(__name__)
-def run_async_celery_task[T](coro_factory: Callable[[], Awaitable[T]]) -> T:
- """Run connector sync work and record aggregate connector metrics."""
+def run_async_celery_task[T](
+ coro_factory: Callable[[], Awaitable[T]],
+ *,
+ workspace_id: int | None = None,
+ user_id: str | None = None,
+) -> T:
+ """Run connector sync work and record aggregate connector metrics.
+
+ When ``workspace_id``/``user_id`` are provided, also emits the PostHog
+ ``connector_indexing_completed``/``_failed`` outcome (the frontend only
+ knows indexing *started*, never whether it worked). No-op without a key.
+ """
task_name = getattr(current_task, "name", None) or "unknown"
t0 = time.perf_counter()
status = "failed"
@@ -45,6 +57,29 @@ def run_async_celery_task[T](coro_factory: Callable[[], Awaitable[T]]) -> T:
status=status,
error_category=error_category,
)
+ if user_id and ph_analytics.is_enabled():
+ event = (
+ "connector_indexing_completed"
+ if status == "success"
+ else "connector_indexing_failed"
+ )
+ with contextlib.suppress(Exception):
+ ph_analytics.capture(
+ event,
+ distinct_id=str(user_id),
+ properties={
+ "workspace_id": workspace_id,
+ # ``connector_type`` is the Celery task name, e.g.
+ # index_notion_pages / index_github_repos.
+ "connector_type": task_name,
+ "status": status,
+ "error_category": error_category,
+ "duration_ms": int(elapsed_s * 1000),
+ },
+ groups={"workspace": str(workspace_id)}
+ if workspace_id is not None
+ else None,
+ )
def _handle_greenlet_error(e: Exception, task_name: str, connector_id: int) -> None:
@@ -91,7 +126,9 @@ def index_notion_pages_task(
return run_async_celery_task(
lambda: _index_notion_pages(
connector_id, workspace_id, user_id, start_date, end_date
- )
+ ),
+ workspace_id=workspace_id,
+ user_id=user_id,
)
except Exception as e:
_handle_greenlet_error(e, "index_notion_pages", connector_id)
@@ -129,7 +166,9 @@ def index_github_repos_task(
return run_async_celery_task(
lambda: _index_github_repos(
connector_id, workspace_id, user_id, start_date, end_date
- )
+ ),
+ workspace_id=workspace_id,
+ user_id=user_id,
)
@@ -164,7 +203,9 @@ def index_confluence_pages_task(
return run_async_celery_task(
lambda: _index_confluence_pages(
connector_id, workspace_id, user_id, start_date, end_date
- )
+ ),
+ workspace_id=workspace_id,
+ user_id=user_id,
)
@@ -200,7 +241,9 @@ def index_google_calendar_events_task(
return run_async_celery_task(
lambda: _index_google_calendar_events(
connector_id, workspace_id, user_id, start_date, end_date
- )
+ ),
+ workspace_id=workspace_id,
+ user_id=user_id,
)
except Exception as e:
_handle_greenlet_error(e, "index_google_calendar_events", connector_id)
@@ -238,7 +281,9 @@ def index_google_gmail_messages_task(
return run_async_celery_task(
lambda: _index_google_gmail_messages(
connector_id, workspace_id, user_id, start_date, end_date
- )
+ ),
+ workspace_id=workspace_id,
+ user_id=user_id,
)
@@ -275,7 +320,9 @@ def index_google_drive_files_task(
workspace_id,
user_id,
items_dict,
- )
+ ),
+ workspace_id=workspace_id,
+ user_id=user_id,
)
@@ -315,7 +362,9 @@ def index_onedrive_files_task(
workspace_id,
user_id,
items_dict,
- )
+ ),
+ workspace_id=workspace_id,
+ user_id=user_id,
)
@@ -355,7 +404,9 @@ def index_dropbox_files_task(
workspace_id,
user_id,
items_dict,
- )
+ ),
+ workspace_id=workspace_id,
+ user_id=user_id,
)
@@ -393,7 +444,9 @@ def index_elasticsearch_documents_task(
return run_async_celery_task(
lambda: _index_elasticsearch_documents(
connector_id, workspace_id, user_id, start_date, end_date
- )
+ ),
+ workspace_id=workspace_id,
+ user_id=user_id,
)
@@ -428,7 +481,9 @@ def index_bookstack_pages_task(
return run_async_celery_task(
lambda: _index_bookstack_pages(
connector_id, workspace_id, user_id, start_date, end_date
- )
+ ),
+ workspace_id=workspace_id,
+ user_id=user_id,
)
@@ -463,7 +518,9 @@ def index_composio_connector_task(
return run_async_celery_task(
lambda: _index_composio_connector(
connector_id, workspace_id, user_id, start_date, end_date
- )
+ ),
+ workspace_id=workspace_id,
+ user_id=user_id,
)
diff --git a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py
index e2ca5345e..8e20275b7 100644
--- a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py
+++ b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py
@@ -4,11 +4,13 @@ import asyncio
import contextlib
import logging
import os
+import time
from uuid import UUID
from app.celery_app import celery_app
from app.config import config
from app.notifications.service import NotificationService
+from app.observability import analytics as ph_analytics
from app.observability import metrics as ot_metrics
from app.services.task_logging_service import TaskLoggingService
from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task
@@ -22,6 +24,44 @@ from app.tasks.document_processors import (
logger = logging.getLogger(__name__)
+
+def _capture_doc_processing(
+ status: str,
+ *,
+ user_id: str | None,
+ workspace_id: int,
+ doc_type: str,
+ file_size: int | None = None,
+ duration_ms: int | None = None,
+) -> None:
+ """Emit ``document_processing_completed``/``_failed`` from a Celery task.
+
+ The frontend only knows the upload POST succeeded, never whether ingestion
+ actually worked — this is the authoritative outcome. No-op when PostHog is
+ unconfigured. ``distinct_id`` is the owning user's id so it joins the same
+ person the web app identifies.
+ """
+ if not ph_analytics.is_enabled() or not user_id:
+ return
+ event = (
+ "document_processing_completed"
+ if status == "success"
+ else "document_processing_failed"
+ )
+ ph_analytics.capture(
+ event,
+ distinct_id=str(user_id),
+ properties={
+ "workspace_id": workspace_id,
+ "doc_type": doc_type,
+ "file_size": file_size,
+ "duration_ms": duration_ms,
+ "status": status,
+ },
+ groups={"workspace": str(workspace_id)},
+ )
+
+
# ===== Redis heartbeat for document processing tasks =====
# Same mechanism as connector indexing heartbeats (search_source_connectors_routes.py).
# A background coroutine refreshes a Redis key every 60s with a 2-min TTL.
@@ -268,11 +308,30 @@ def process_extension_document_task(
workspace_id: ID of the workspace
user_id: ID of the user
"""
- return run_async_celery_task(
- lambda: _process_extension_document(
- individual_document_dict, workspace_id, user_id
+ _t0 = time.perf_counter()
+ try:
+ result = run_async_celery_task(
+ lambda: _process_extension_document(
+ individual_document_dict, workspace_id, user_id
+ )
)
+ except Exception:
+ _capture_doc_processing(
+ "failed",
+ user_id=user_id,
+ workspace_id=workspace_id,
+ doc_type="extension",
+ duration_ms=int((time.perf_counter() - _t0) * 1000),
+ )
+ raise
+ _capture_doc_processing(
+ "success",
+ user_id=user_id,
+ workspace_id=workspace_id,
+ doc_type="extension",
+ duration_ms=int((time.perf_counter() - _t0) * 1000),
)
+ return result
async def _process_extension_document(
@@ -430,12 +489,14 @@ def process_file_upload_task(
)
return
+ file_size: int | None = None
try:
file_size = os.path.getsize(file_path)
logger.info(f"[process_file_upload] File size: {file_size} bytes")
except Exception as e:
logger.warning(f"[process_file_upload] Could not get file size: {e}")
+ _t0 = time.perf_counter()
try:
run_async_celery_task(
lambda: _process_file_upload(file_path, filename, workspace_id, user_id)
@@ -448,7 +509,23 @@ def process_file_upload_task(
f"[process_file_upload] Task failed for {filename}: {e}\n"
f"Traceback:\n{traceback.format_exc()}"
)
+ _capture_doc_processing(
+ "failed",
+ user_id=user_id,
+ workspace_id=workspace_id,
+ doc_type="file_upload",
+ file_size=file_size,
+ duration_ms=int((time.perf_counter() - _t0) * 1000),
+ )
raise
+ _capture_doc_processing(
+ "success",
+ user_id=user_id,
+ workspace_id=workspace_id,
+ doc_type="file_upload",
+ file_size=file_size,
+ duration_ms=int((time.perf_counter() - _t0) * 1000),
+ )
async def _process_file_upload(
@@ -682,6 +759,7 @@ def process_file_upload_with_document_task(
)
return
+ _t0 = time.perf_counter()
try:
run_async_celery_task(
lambda: _process_file_with_document(
@@ -702,7 +780,21 @@ def process_file_upload_with_document_task(
f"[process_file_upload_with_document] Task failed for {filename}: {e}\n"
f"Traceback:\n{traceback.format_exc()}"
)
+ _capture_doc_processing(
+ "failed",
+ user_id=user_id,
+ workspace_id=workspace_id,
+ doc_type="file_upload_2phase",
+ duration_ms=int((time.perf_counter() - _t0) * 1000),
+ )
raise
+ _capture_doc_processing(
+ "success",
+ user_id=user_id,
+ workspace_id=workspace_id,
+ doc_type="file_upload_2phase",
+ duration_ms=int((time.perf_counter() - _t0) * 1000),
+ )
async def _mark_document_failed(document_id: int, reason: str):
diff --git a/surfsense_backend/app/tasks/celery_tasks/video_presentation_tasks.py b/surfsense_backend/app/tasks/celery_tasks/video_presentation_tasks.py
index 4f934cc27..c91d3031c 100644
--- a/surfsense_backend/app/tasks/celery_tasks/video_presentation_tasks.py
+++ b/surfsense_backend/app/tasks/celery_tasks/video_presentation_tasks.py
@@ -12,6 +12,7 @@ from app.agents.video_presentation.state import State as VideoPresentationState
from app.celery_app import celery_app
from app.config import config as app_config
from app.db import VideoPresentation, VideoPresentationStatus
+from app.observability import analytics as ph_analytics
from app.services.billable_calls import (
BillingSettlementError,
QuotaInsufficientError,
@@ -239,6 +240,20 @@ async def _generate_video_presentation(
logger.info(f"Successfully generated video presentation: {video_pres.id}")
+ # Credit-consuming deliverable — the frontend never confirms
+ # completion. Attributed to the workspace owner resolved above.
+ if owner_user_id:
+ ph_analytics.capture(
+ "video_presentation_generated",
+ distinct_id=str(owner_user_id),
+ properties={
+ "workspace_id": workspace_id,
+ "video_presentation_id": video_pres.id,
+ "slide_count": len(serializable_slides),
+ },
+ groups={"workspace": str(workspace_id)},
+ )
+
return {
"status": "ready",
"video_presentation_id": video_pres.id,
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/orchestrator.py b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/orchestrator.py
index 4451b4b37..a6544b810 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/orchestrator.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/new_chat/orchestrator.py
@@ -97,6 +97,10 @@ from app.tasks.chat.streaming.flows.shared.rate_limit_recovery import (
log_rate_limit_recovered,
reroute_to_next_auto_pin,
)
+from app.tasks.chat.streaming.flows.shared.analytics import (
+ build_llm_callback_handler,
+ capture_chat_turn_completed,
+)
from app.tasks.chat.streaming.flows.shared.span import (
close_chat_request_span,
open_chat_request_span,
@@ -487,6 +491,23 @@ async def stream_new_chat(
"recursion_limit": 10_000,
}
+ # PostHog LLM analytics: attach a callback so the full agent trace
+ # tree (LLM calls, tools, subagents) is captured per turn. No-op when
+ # PostHog is unconfigured.
+ _llm_handler = build_llm_callback_handler(
+ distinct_id=user_id,
+ trace_id=stream_result.turn_id,
+ properties={
+ "workspace_id": workspace_id,
+ "chat_id": chat_id,
+ "$ai_session_id": str(chat_id),
+ "flow": flow,
+ },
+ groups={"workspace": str(workspace_id)},
+ )
+ if _llm_handler is not None:
+ config["callbacks"] = [_llm_handler]
+
# --- Block 4: First SSE frames ---
for sse in iter_initial_frames(
@@ -830,6 +851,27 @@ async def stream_new_chat(
log_prefix="stream_new_chat",
)
+ # Authoritative server-side product event. Inside the shield so it
+ # survives client-disconnect cancellation (abandoned tabs still
+ # produce a turn event), and while ``stream_result`` is still live
+ # (it's dropped to None below for GC).
+ capture_chat_turn_completed(
+ flow=flow,
+ outcome=chat_outcome,
+ error_category=chat_error_category,
+ workspace_id=workspace_id,
+ chat_id=chat_id,
+ user_id=user_id,
+ auth_context=auth_context,
+ agent_mode=chat_agent_mode,
+ client_platform=fs_platform,
+ filesystem_mode=fs_mode,
+ turn_id=getattr(stream_result, "turn_id", None),
+ request_id=request_id,
+ duration_ms=int((time.perf_counter() - _t_total) * 1000),
+ accumulator=accumulator,
+ )
+
# Persist any sandbox-produced files to local storage so they remain
# downloadable after the Daytona sandbox auto-deletes.
if stream_result and stream_result.sandbox_files:
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/orchestrator.py b/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/orchestrator.py
index 8b7956f4c..8dd749aee 100644
--- a/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/orchestrator.py
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/resume_chat/orchestrator.py
@@ -76,6 +76,10 @@ from app.tasks.chat.streaming.flows.shared.rate_limit_recovery import (
log_rate_limit_recovered,
reroute_to_next_auto_pin,
)
+from app.tasks.chat.streaming.flows.shared.analytics import (
+ build_llm_callback_handler,
+ capture_chat_turn_completed,
+)
from app.tasks.chat.streaming.flows.shared.span import (
close_chat_request_span,
open_chat_request_span,
@@ -387,6 +391,22 @@ async def stream_resume_chat(
"recursion_limit": 10_000,
}
+ # PostHog LLM analytics — same trace as the original turn's
+ # conversation via ``$ai_session_id``. No-op when unconfigured.
+ _llm_handler = build_llm_callback_handler(
+ distinct_id=user_id,
+ trace_id=stream_result.turn_id,
+ properties={
+ "workspace_id": workspace_id,
+ "chat_id": chat_id,
+ "$ai_session_id": str(chat_id),
+ "flow": "resume",
+ },
+ groups={"workspace": str(workspace_id)},
+ )
+ if _llm_handler is not None:
+ config["callbacks"] = [_llm_handler]
+
# --- First SSE frames ---
for sse in iter_initial_frames(
@@ -599,6 +619,25 @@ async def stream_resume_chat(
log_prefix="stream_resume",
)
+ # Authoritative server-side product event (see new_chat
+ # orchestrator for rationale). ``flow="resume"``.
+ capture_chat_turn_completed(
+ flow="resume",
+ outcome=chat_outcome,
+ error_category=chat_error_category,
+ workspace_id=workspace_id,
+ chat_id=chat_id,
+ user_id=user_id,
+ auth_context=auth_context,
+ agent_mode=chat_agent_mode,
+ client_platform=fs_platform,
+ filesystem_mode=fs_mode,
+ turn_id=getattr(stream_result, "turn_id", None),
+ request_id=request_id,
+ duration_ms=int((time.perf_counter() - _t_total) * 1000),
+ accumulator=accumulator,
+ )
+
# Release the lock from the original interrupted turn or any
# re-interrupt/bailout. Skip on ``BusyError`` (lock not held here).
if not busy_error_raised:
diff --git a/surfsense_backend/app/tasks/chat/streaming/flows/shared/analytics.py b/surfsense_backend/app/tasks/chat/streaming/flows/shared/analytics.py
new file mode 100644
index 000000000..2d311c26b
--- /dev/null
+++ b/surfsense_backend/app/tasks/chat/streaming/flows/shared/analytics.py
@@ -0,0 +1,117 @@
+"""PostHog chat-turn analytics for streaming flows.
+
+Emits a single authoritative ``chat_turn_completed`` product event per turn,
+shared by the new-chat and resume orchestrators so every chat source (web,
+desktop, PAT scripts, gateway, automations) is tracked identically —
+including sources the frontend can never observe. No-op when PostHog is
+unconfigured.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Any
+
+from app.config import config
+from app.observability import analytics
+
+if TYPE_CHECKING:
+ from app.auth.context import AuthContext
+ from app.services.token_tracking_service import TurnTokenAccumulator
+
+logger = logging.getLogger(__name__)
+
+
+def build_llm_callback_handler(
+ *,
+ distinct_id: str | None,
+ trace_id: str | None,
+ properties: dict[str, Any] | None = None,
+ groups: dict[str, str] | None = None,
+) -> Any | None:
+ """Build a PostHog LangChain ``CallbackHandler`` for a chat turn.
+
+ Attaching this to the LangGraph ``config["callbacks"]`` captures the full
+ agent trace tree ($ai_trace / $ai_span / $ai_generation) — every LLM call,
+ tool, subagent, and retriever — joined to the ``chat_turn_completed`` event
+ via ``trace_id`` (the turn id) and grouped per conversation via
+ ``$ai_session_id`` (in ``properties``).
+
+ Returns ``None`` when PostHog is disabled or the package/handler is
+ unavailable, so callers can simply skip attaching callbacks. ``privacy_mode``
+ (default on) suppresses prompt/completion bodies — chat content includes
+ users' private documents.
+ """
+ client_obj = analytics.get_client()
+ if client_obj is None or not distinct_id:
+ return None
+
+ try:
+ from posthog.ai.langchain import CallbackHandler
+
+ return CallbackHandler(
+ client=client_obj,
+ distinct_id=distinct_id,
+ trace_id=trace_id,
+ properties=properties or {},
+ privacy_mode=config.POSTHOG_AI_PRIVACY_MODE,
+ groups=groups,
+ )
+ except Exception:
+ logger.debug("PostHog LLM callback handler unavailable", exc_info=True)
+ return None
+
+
+def capture_chat_turn_completed(
+ *,
+ flow: str,
+ outcome: str,
+ error_category: str | None,
+ workspace_id: int,
+ chat_id: int,
+ user_id: str | None,
+ auth_context: AuthContext | None,
+ agent_mode: str,
+ client_platform: str,
+ filesystem_mode: str,
+ turn_id: str | None,
+ request_id: str | None,
+ duration_ms: int,
+ accumulator: TurnTokenAccumulator,
+) -> None:
+ """Capture ``chat_turn_completed``. Best-effort; never raises."""
+ if not analytics.is_enabled() or not user_id:
+ return
+
+ props: dict[str, Any] = {
+ "flow": flow,
+ "outcome": outcome,
+ "error_category": error_category,
+ "workspace_id": workspace_id,
+ "chat_id": chat_id,
+ "agent_mode": agent_mode,
+ "client_platform": client_platform,
+ "filesystem_mode": filesystem_mode,
+ "turn_id": turn_id,
+ "request_id": request_id,
+ "duration_ms": duration_ms,
+ # Cost is micro-USD (integer), matching TurnTokenAccumulator; do not
+ # convert to float dollars.
+ "total_tokens": accumulator.grand_total,
+ "prompt_tokens": accumulator.total_prompt_tokens,
+ "completion_tokens": accumulator.total_completion_tokens,
+ "cost_micros": accumulator.total_cost_micros,
+ }
+ groups = {"workspace": str(workspace_id)}
+
+ if auth_context is not None:
+ analytics.capture_for(
+ auth_context, "chat_turn_completed", props, groups=groups
+ )
+ else:
+ analytics.capture(
+ "chat_turn_completed",
+ distinct_id=user_id,
+ properties=props,
+ groups=groups,
+ )
diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py
index dbec93876..633b08443 100644
--- a/surfsense_backend/app/users.py
+++ b/surfsense_backend/app/users.py
@@ -20,6 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext
from app.auth.session_cookies import access_expires_at, write_session
from app.config import config
+from app.observability import analytics as ph_analytics
from app.db import (
Prompt,
User,
@@ -144,6 +145,9 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
except Exception as e:
logger.warning(f"Failed to update last_login for user {user.id}: {e}")
+ # Authoritative login event (vs. the frontend's optimistic capture).
+ ph_analytics.capture("auth_login_success", distinct_id=str(user.id))
+
async def on_after_register(self, user: User, request: Request | None = None):
"""
Called after a user registers. Creates a default workspace for the user
@@ -206,6 +210,17 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
logger.info(
f"Created default workspace (ID: {default_workspace.id}) for user {user.id}"
)
+
+ # Authoritative registration + auto-created default workspace.
+ ph_analytics.capture(
+ "auth_registration_success", distinct_id=str(user.id)
+ )
+ ph_analytics.capture(
+ "workspace_created",
+ distinct_id=str(user.id),
+ properties={"client": "auto_register"},
+ groups={"workspace": str(default_workspace.id)},
+ )
except Exception as e:
logger.error(f"Failed to create default workspace for user {user.id}: {e}")
@@ -338,6 +353,13 @@ async def get_auth_context(
FastAPI-Users still handles JWT mechanics; PATs are resolved here so RBAC
receives the full SurfSense principal instead of a bare User.
"""
+ def _stash(ctx: AuthContext) -> AuthContext:
+ # Expose the resolved principal on request.state so downstream
+ # middleware (e.g. PostHog pat_api_request attribution) can read it
+ # without re-resolving auth.
+ request.state.auth_context = ctx
+ return ctx
+
auth_header = request.headers.get("Authorization")
if auth_header:
scheme, _, credential = auth_header.partition(" ")
@@ -348,7 +370,7 @@ async def get_auth_context(
pat = await resolve_pat(session, token)
if pat and pat.user and pat.user.is_active:
maybe_touch_last_used(pat)
- return AuthContext.pat_auth(pat.user, pat)
+ return _stash(AuthContext.pat_auth(pat.user, pat))
if is_bearer and _token_meets_epoch(token):
try:
@@ -358,7 +380,7 @@ async def get_auth_context(
user = None
if user and user.is_active:
- return AuthContext.session(user)
+ return _stash(AuthContext.session(user))
cookie_token = request.cookies.get(config.SESSION_COOKIE_NAME)
if cookie_token and _token_meets_epoch(cookie_token):
@@ -369,7 +391,7 @@ async def get_auth_context(
user = None
if user and user.is_active:
- return AuthContext.session(user)
+ return _stash(AuthContext.session(user))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
diff --git a/surfsense_backend/pyproject.toml b/surfsense_backend/pyproject.toml
index bd346b306..955178320 100644
--- a/surfsense_backend/pyproject.toml
+++ b/surfsense_backend/pyproject.toml
@@ -86,6 +86,7 @@ dependencies = [
"python-telegram-bot>=22.7",
"croniter>=2.0.0",
"scrapling[fetchers]>=0.4.11",
+ "posthog>=6.0.0",
]
[project.optional-dependencies]
diff --git a/surfsense_backend/tests/unit/observability/test_analytics.py b/surfsense_backend/tests/unit/observability/test_analytics.py
new file mode 100644
index 000000000..4cacb23c0
--- /dev/null
+++ b/surfsense_backend/tests/unit/observability/test_analytics.py
@@ -0,0 +1,149 @@
+"""Unit tests for the PostHog analytics wrapper.
+
+Covers the two properties the rest of the codebase relies on:
+1. Opt-in no-op: with no client configured, every entry point is silent and
+ never raises (analytics must never break a request).
+2. Correct stamping when a client IS present: ``source="backend"`` and
+ ``disable_geoip=True`` on every capture, plus ``auth_method`` / ``client``
+ derived from the ``AuthContext`` principal by ``capture_for``.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+
+from app.observability import analytics
+
+pytestmark = pytest.mark.unit
+
+
+class _FakeClient:
+ """Records capture()/group_identify() calls instead of hitting the network."""
+
+ def __init__(self) -> None:
+ self.captures: list[dict] = []
+ self.groups: list[dict] = []
+
+ def capture(self, event, *, distinct_id, properties, groups, disable_geoip):
+ self.captures.append(
+ {
+ "event": event,
+ "distinct_id": distinct_id,
+ "properties": properties,
+ "groups": groups,
+ "disable_geoip": disable_geoip,
+ }
+ )
+
+ def group_identify(self, *, group_type, group_key, properties):
+ self.groups.append(
+ {"group_type": group_type, "group_key": group_key, "properties": properties}
+ )
+
+
+@pytest.fixture(autouse=True)
+def _reset_module_state():
+ """Isolate the module-level lazy-client singleton between tests."""
+ orig_client = analytics._client
+ orig_attempted = analytics._init_attempted
+ yield
+ analytics._client = orig_client
+ analytics._init_attempted = orig_attempted
+
+
+def _use_fake_client() -> _FakeClient:
+ """Inject a fake client, bypassing lazy init (no posthog import, no key)."""
+ fake = _FakeClient()
+ analytics._client = fake
+ analytics._init_attempted = True
+ return fake
+
+
+def _disable_client() -> None:
+ analytics._client = None
+ analytics._init_attempted = True
+
+
+# ---- No-op behaviour when disabled -------------------------------------------------
+
+
+def test_capture_is_noop_without_client():
+ _disable_client()
+ assert analytics.is_enabled() is False
+ # Must not raise even though there is no client.
+ analytics.capture("some_event", distinct_id="u1", properties={"a": 1})
+
+
+def test_capture_for_is_noop_without_client():
+ _disable_client()
+ auth = SimpleNamespace(method="session", source=None, user=SimpleNamespace(id="u1"))
+ analytics.capture_for(auth, "some_event", {"a": 1}) # no raise
+
+
+def test_shutdown_is_noop_without_client():
+ _disable_client()
+ analytics.shutdown() # no raise
+
+
+# ---- Stamping when a client is present ---------------------------------------------
+
+
+def test_capture_stamps_source_and_disables_geoip():
+ fake = _use_fake_client()
+ analytics.capture(
+ "chat_turn_completed",
+ distinct_id="user-123",
+ properties={"workspace_id": 7},
+ groups={"workspace": "7"},
+ )
+
+ assert len(fake.captures) == 1
+ call = fake.captures[0]
+ assert call["event"] == "chat_turn_completed"
+ assert call["distinct_id"] == "user-123"
+ # source is always stamped so backend vs frontend events are separable.
+ assert call["properties"]["source"] == "backend"
+ assert call["properties"]["workspace_id"] == 7
+ assert call["groups"] == {"workspace": "7"}
+ # Without this the server IP would overwrite each person's real location.
+ assert call["disable_geoip"] is True
+
+
+def test_capture_never_raises_when_client_errors():
+ class _Boom(_FakeClient):
+ def capture(self, *a, **k):
+ raise RuntimeError("network down")
+
+ boom = _Boom()
+ analytics._client = boom
+ analytics._init_attempted = True
+ # Swallowed like the frontend safeCapture — analytics never breaks a request.
+ analytics.capture("evt", distinct_id="u1")
+
+
+@pytest.mark.parametrize(
+ ("method", "source", "expected_client"),
+ [
+ ("session", None, "web"),
+ ("pat", None, "pat"),
+ ("system", "gateway", "gateway"),
+ ("system", None, "system"),
+ ],
+)
+def test_capture_for_stamps_auth_method_and_client(method, source, expected_client):
+ fake = _use_fake_client()
+ auth = SimpleNamespace(
+ method=method, source=source, user=SimpleNamespace(id="user-abc")
+ )
+
+ analytics.capture_for(auth, "workspace_created", {"workspace_id": 1})
+
+ assert len(fake.captures) == 1
+ props = fake.captures[0]["properties"]
+ assert fake.captures[0]["distinct_id"] == "user-abc"
+ assert props["auth_method"] == method
+ assert props["client"] == expected_client
+ assert props["source"] == "backend" # capture_for delegates to capture
+ assert props["workspace_id"] == 1
diff --git a/surfsense_backend/uv.lock b/surfsense_backend/uv.lock
index 3a27770b6..5f8ec906a 100644
--- a/surfsense_backend/uv.lock
+++ b/surfsense_backend/uv.lock
@@ -48,6 +48,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -94,6 +97,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -140,6 +146,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -186,6 +195,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -232,6 +244,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -278,6 +293,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -324,6 +342,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -370,6 +391,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -416,6 +440,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -462,6 +489,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -523,6 +553,10 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -569,6 +603,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -615,6 +652,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -661,6 +701,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -707,6 +750,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -753,6 +799,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -799,6 +848,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -845,6 +897,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -891,6 +946,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -937,6 +995,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -983,6 +1044,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1044,6 +1108,10 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1090,6 +1158,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1136,6 +1207,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1182,6 +1256,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1228,6 +1305,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1274,6 +1354,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1320,6 +1403,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1366,6 +1452,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1412,6 +1501,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1458,6 +1550,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1504,6 +1599,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1565,6 +1663,10 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1611,6 +1713,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1702,6 +1807,12 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1748,6 +1859,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1794,6 +1908,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1885,6 +2002,12 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -1976,6 +2099,12 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'",
"python_version < '0'",
@@ -2022,6 +2151,9 @@ resolution-markers = [
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
+ "python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
]
conflicts = [[
@@ -2557,6 +2689,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845 },
]
+[[package]]
+name = "backoff"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148 },
+]
+
[[package]]
name = "banks"
version = "2.4.1"
@@ -8650,6 +8791,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424 },
]
+[[package]]
+name = "posthog"
+version = "7.28.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "backoff" },
+ { name = "distro" },
+ { name = "requests" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/36/f0/3af875ac3fd5863ed4874c9618d85eab3f7fd24b395827d99da0ef90aca6/posthog-7.28.0.tar.gz", hash = "sha256:9e048dee58f27373db622c0744be30c1a2b7f1df31049956d6341c9646fb3833", size = 356360 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1c/8b/d41d98e64bd6ce650d45690cffc718274b1eda65e3bfd79575a1cf95b45c/posthog-7.28.0-py3-none-any.whl", hash = "sha256:4cff10062807bbd8ae6ec2804a71584831a75b390ce7ba3e736bf4cc0fb25d28", size = 425147 },
+]
+
[[package]]
name = "preshed"
version = "3.0.13"
@@ -10926,6 +11082,7 @@ dependencies = [
{ name = "opentelemetry-sdk" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "pgvector" },
+ { name = "posthog" },
{ name = "psycopg", extra = ["binary", "pool"] },
{ name = "pyarrow" },
{ name = "pyjwt" },
@@ -11041,6 +11198,7 @@ requires-dist = [
{ name = "opentelemetry-sdk", specifier = ">=1.40.0" },
{ name = "opentelemetry-semantic-conventions", specifier = ">=0.61b0" },
{ name = "pgvector", specifier = ">=0.3.6" },
+ { name = "posthog", specifier = ">=6.0.0" },
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.2" },
{ name = "pyarrow", specifier = ">=15.0.0,<19.0.0" },
{ name = "pyjwt", specifier = ">=2.12.0" },
diff --git a/surfsense_mcp/mcp_server/core/client.py b/surfsense_mcp/mcp_server/core/client.py
index d65b47b7e..2bdf405dc 100644
--- a/surfsense_mcp/mcp_server/core/client.py
+++ b/surfsense_mcp/mcp_server/core/client.py
@@ -37,7 +37,11 @@ class SurfSenseClient:
self._fallback_api_key = fallback_api_key
self._http = httpx.AsyncClient(
base_url=api_base,
- headers={"Accept": "application/json"},
+ # ``X-SurfSense-Client`` lets the backend distinguish PAT traffic
+ # originating from this MCP server vs. raw PAT scripts, so
+ # "documents added via MCP" / "searches via MCP" are queryable.
+ # Server-to-server, so no CORS implications.
+ headers={"Accept": "application/json", "X-SurfSense-Client": "mcp"},
timeout=timeout,
)
diff --git a/surfsense_web/app/(home)/login/LocalLoginForm.tsx b/surfsense_web/app/(home)/login/LocalLoginForm.tsx
index dd415e10f..e60463b9d 100644
--- a/surfsense_web/app/(home)/login/LocalLoginForm.tsx
+++ b/surfsense_web/app/(home)/login/LocalLoginForm.tsx
@@ -13,7 +13,7 @@ import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
import { getPostLoginRedirectPath } from "@/lib/auth-utils";
import { ValidationError } from "@/lib/error";
-import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events";
+import { trackLoginAttempt, trackLoginFailure } from "@/lib/posthog/events";
export function LocalLoginForm() {
const t = useTranslations("auth");
@@ -45,8 +45,8 @@ export function LocalLoginForm() {
grant_type: "password",
});
- // Track successful login
- trackLoginSuccess("local");
+ // auth_login_success is now emitted server-side
+ // (UserManager.on_after_login) — authoritative vs. optimistic.
// Small delay to show success message
setTimeout(() => {
diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx
index 571103e79..5c91c7064 100644
--- a/surfsense_web/app/(home)/register/page.tsx
+++ b/surfsense_web/app/(home)/register/page.tsx
@@ -15,11 +15,7 @@ import { Spinner } from "@/components/ui/spinner";
import { useSession } from "@/hooks/use-session";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AppError, ValidationError } from "@/lib/error";
-import {
- trackRegistrationAttempt,
- trackRegistrationFailure,
- trackRegistrationSuccess,
-} from "@/lib/posthog/events";
+import { trackRegistrationAttempt, trackRegistrationFailure } from "@/lib/posthog/events";
import { AmbientBackground } from "../login/AmbientBackground";
export default function RegisterPage() {
@@ -81,8 +77,8 @@ export default function RegisterPage() {
is_verified: false,
});
- // Track successful registration
- trackRegistrationSuccess();
+ // auth_registration_success is now emitted server-side
+ // (UserManager.on_after_register) — authoritative vs. optimistic.
// Success toast
toast.success(t("register_success"), {
diff --git a/surfsense_web/app/dashboard/[workspace_id]/team/team-content.tsx b/surfsense_web/app/dashboard/[workspace_id]/team/team-content.tsx
index 571512305..dc45462e8 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/team/team-content.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/team/team-content.tsx
@@ -96,7 +96,6 @@ import type { Role } from "@/contracts/types/roles.types";
import { invitesApiService } from "@/lib/apis/invites-api.service";
import { rolesApiService } from "@/lib/apis/roles-api.service";
import { formatRelativeDate } from "@/lib/format-date";
-import { trackWorkspaceInviteSent, trackWorkspaceUsersViewed } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
@@ -226,12 +225,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
const canPrev = pageIndex > 0;
const canNext = displayEnd < totalItems;
- useEffect(() => {
- if (members.length > 0 && !membersLoading) {
- const ownerCount = members.filter((m) => m.is_owner).length;
- trackWorkspaceUsersViewed(workspaceId, members.length, ownerCount);
- }
- }, [members, membersLoading, workspaceId]);
+ // workspace_users_viewed removed — redundant with $pageview.
if (accessLoading || membersLoading) {
return (
@@ -342,11 +336,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
Invite members
) : (
-
+
)}
{invitesLoading ? (
Promise;
- workspaceId: number;
}) {
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
@@ -653,12 +641,7 @@ function CreateInviteDialog({
const invite = await onCreateInvite(data);
setCreatedInvite(invite);
- const roleName = roleId ? roles.find((r) => r.id.toString() === roleId)?.name : undefined;
- trackWorkspaceInviteSent(workspaceId, {
- roleName,
- hasExpiry: !!expiresAt,
- hasMaxUses: !!maxUses,
- });
+ // workspace_invite_sent is now emitted server-side (rbac_routes.create_invite).
} catch (error) {
console.error("Failed to create invite:", error);
toast.error("Failed to create invite. Please try again.");
diff --git a/surfsense_web/app/invite/[invite_code]/page.tsx b/surfsense_web/app/invite/[invite_code]/page.tsx
index 2384ca422..4d61b742c 100644
--- a/surfsense_web/app/invite/[invite_code]/page.tsx
+++ b/surfsense_web/app/invite/[invite_code]/page.tsx
@@ -16,7 +16,7 @@ import { motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
-import { use, useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { acceptInviteMutationAtom } from "@/atoms/invites/invites-mutation.atoms";
import { Button } from "@/components/ui/button";
@@ -33,11 +33,7 @@ import type { AcceptInviteResponse } from "@/contracts/types/invites.types";
import { useSession } from "@/hooks/use-session";
import { invitesApiService } from "@/lib/apis/invites-api.service";
import { setRedirectPath } from "@/lib/auth-utils";
-import {
- trackWorkspaceInviteAccepted,
- trackWorkspaceInviteDeclined,
- trackWorkspaceUserAdded,
-} from "@/lib/posthog/events";
+import { trackWorkspaceInviteDeclined } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export default function InviteAcceptPage() {
@@ -96,9 +92,9 @@ export default function InviteAcceptPage() {
setAccepted(true);
setAcceptedData(result);
- // Track invite accepted and user added events
- trackWorkspaceInviteAccepted(result.workspace_id, result.workspace_name, result.role_name);
- trackWorkspaceUserAdded(result.workspace_id, result.workspace_name, result.role_name);
+ // workspace_invite_accepted + workspace_user_added are now emitted
+ // server-side (rbac_routes.accept_invite) — the server redirect is
+ // the authoritative join point.
}
} catch (err: any) {
setError(err.message || "Failed to accept invite");
diff --git a/surfsense_web/atoms/automations/automations-mutation.atoms.ts b/surfsense_web/atoms/automations/automations-mutation.atoms.ts
index f96b2a252..8e8ddbb16 100644
--- a/surfsense_web/atoms/automations/automations-mutation.atoms.ts
+++ b/surfsense_web/atoms/automations/automations-mutation.atoms.ts
@@ -8,18 +8,11 @@ import type {
} from "@/contracts/types/automation.types";
import { automationsApiService } from "@/lib/apis/automations-api.service";
import {
- trackAutomationCreated,
trackAutomationCreateFailed,
- trackAutomationDeleted,
trackAutomationDeleteFailed,
- trackAutomationStatusChanged,
- trackAutomationTriggerAdded,
trackAutomationTriggerAddFailed,
- trackAutomationTriggerRemoved,
trackAutomationTriggerRemoveFailed,
- trackAutomationTriggerUpdated,
trackAutomationTriggerUpdateFailed,
- trackAutomationUpdated,
trackAutomationUpdateFailed,
} from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
@@ -48,20 +41,10 @@ export const createAutomationMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: AutomationCreateRequest) => {
return automationsApiService.createAutomation(request);
},
- onSuccess: (automation, variables) => {
+ onSuccess: (_automation, variables) => {
invalidateList(variables.workspace_id);
toast.success("Automation created");
- trackAutomationCreated({
- workspace_id: variables.workspace_id,
- automation_id: automation.id,
- task_count: variables.definition.plan.length,
- trigger_type: variables.triggers?.[0]?.type ?? "none",
- has_schedule: (variables.triggers?.length ?? 0) > 0,
- chat_model_id: variables.definition.models?.chat_model_id,
- image_gen_model_id: variables.definition.models?.image_gen_model_id,
- vision_model_id: variables.definition.models?.vision_model_id,
- tags_count: variables.definition.metadata?.tags?.length,
- });
+ // automation_created is now emitted server-side (AutomationService.create).
},
onError: (error: Error, variables) => {
console.error("Error creating automation:", error);
@@ -82,24 +65,8 @@ export const updateAutomationMutationAtom = atomWithMutation(() => ({
invalidateDetail(vars.automationId);
invalidateList(automation.workspace_id);
toast.success("Automation updated");
- // A status-only patch (pause/resume/archive) is a distinct action from a
- // definition/name edit, so split it into its own event.
- if (vars.patch.status && !vars.patch.definition) {
- trackAutomationStatusChanged({
- automation_id: vars.automationId,
- workspace_id: automation.workspace_id,
- next_status: vars.patch.status,
- });
- } else {
- trackAutomationUpdated({
- automation_id: vars.automationId,
- workspace_id: automation.workspace_id,
- has_definition_change: !!vars.patch.definition,
- has_name_change: vars.patch.name != null,
- has_description_change: vars.patch.description !== undefined,
- task_count: vars.patch.definition?.plan?.length,
- });
- }
+ // automation_updated / automation_status_changed are now emitted
+ // server-side (AutomationService.update).
},
onError: (error: Error, vars) => {
console.error("Error updating automation:", error);
@@ -121,10 +88,7 @@ export const deleteAutomationMutationAtom = atomWithMutation(() => ({
invalidateList(vars.workspaceId);
invalidateDetail(vars.automationId);
toast.success("Automation deleted");
- trackAutomationDeleted({
- automation_id: vars.automationId,
- workspace_id: vars.workspaceId,
- });
+ // automation_deleted is now emitted server-side (AutomationService.delete).
},
onError: (error: Error, vars) => {
console.error("Error deleting automation:", error);
@@ -141,16 +105,10 @@ export const addTriggerMutationAtom = atomWithMutation(() => ({
mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => {
return automationsApiService.addTrigger(vars.automationId, vars.payload);
},
- onSuccess: (trigger, vars) => {
+ onSuccess: (_trigger, vars) => {
invalidateDetail(vars.automationId);
toast.success("Trigger added");
- trackAutomationTriggerAdded({
- automation_id: vars.automationId,
- trigger_id: trigger.id,
- trigger_type: trigger.type,
- enabled: trigger.enabled,
- has_cron: !!trigger.params?.cron,
- });
+ // automation_trigger_added is now emitted server-side (TriggerService.add).
},
onError: (error: Error, vars) => {
console.error("Error adding trigger:", error);
@@ -174,17 +132,7 @@ export const updateTriggerMutationAtom = atomWithMutation(() => ({
onSuccess: (_, vars) => {
invalidateDetail(vars.automationId);
toast.success("Trigger updated");
- const change: "enabled" | "params" | "other" = vars.patch.params
- ? "params"
- : vars.patch.enabled !== undefined && vars.patch.enabled !== null
- ? "enabled"
- : "other";
- trackAutomationTriggerUpdated({
- automation_id: vars.automationId,
- trigger_id: vars.triggerId,
- change,
- enabled: vars.patch.enabled ?? undefined,
- });
+ // automation_trigger_updated is now emitted server-side (TriggerService.update).
},
onError: (error: Error, vars) => {
console.error("Error updating trigger:", error);
@@ -206,10 +154,7 @@ export const removeTriggerMutationAtom = atomWithMutation(() => ({
onSuccess: (vars) => {
invalidateDetail(vars.automationId);
toast.success("Trigger removed");
- trackAutomationTriggerRemoved({
- automation_id: vars.automationId,
- trigger_id: vars.triggerId,
- });
+ // automation_trigger_removed is now emitted server-side (TriggerService.remove).
},
onError: (error: Error, vars) => {
console.error("Error removing trigger:", error);
diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
index 4e599b577..827ae2699 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
+++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
@@ -21,8 +21,6 @@ import { OAUTH_RESULT_COOKIE, parseOAuthCallbackResult } from "@/contracts/types
import { authenticatedFetch } from "@/lib/auth-fetch";
import { buildBackendUrl } from "@/lib/env-config";
import {
- trackConnectorConnected,
- trackConnectorDeleted,
trackConnectorSetupFailure,
trackConnectorSetupStarted,
trackIndexWithDateRangeOpened,
@@ -296,11 +294,9 @@ export const useConnectorDialog = () => {
if (newConnector && oauthConnector) {
const connectorValidation = searchSourceConnector.safeParse(newConnector);
if (connectorValidation.success) {
- trackConnectorConnected(
- Number(workspaceId),
- oauthConnector.connectorType,
- newConnector.id
- );
+ // connector_connected is now emitted server-side (after_insert
+ // listener in search_source_connectors_routes.py) — covers the
+ // OAuth callback redirect the frontend often never observes.
const isLiveConnector = LIVE_CONNECTOR_TYPES.has(oauthConnector.connectorType);
@@ -436,12 +432,7 @@ export const useConnectorDialog = () => {
if (connector) {
const connectorValidation = searchSourceConnector.safeParse(connector);
if (connectorValidation.success) {
- // Track webcrawler connector connected
- trackConnectorConnected(
- Number(workspaceId),
- EnumConnectorName.WEBCRAWLER_CONNECTOR,
- connector.id
- );
+ // connector_connected is now emitted server-side (after_insert).
const config = validateIndexingConfigState({
connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR,
@@ -540,8 +531,7 @@ export const useConnectorDialog = () => {
// Store connectingConnectorType before clearing it
const currentConnectorType = connectingConnectorType;
- // Track connector connected event for non-OAuth connectors
- trackConnectorConnected(Number(workspaceId), currentConnectorType, connector.id);
+ // connector_connected is now emitted server-side (after_insert).
// Find connector title from constants
const connectorInfo = OTHER_CONNECTORS.find(
@@ -1229,12 +1219,8 @@ export const useConnectorDialog = () => {
id: editingConnector.id,
});
- // Track connector deleted event
- trackConnectorDeleted(
- Number(workspaceId),
- editingConnector.connector_type,
- editingConnector.id
- );
+ // connector_deleted is now emitted server-side
+ // (search_source_connectors_routes.delete_search_source_connector).
toast.success(
editingConnector.connector_type === "MCP_CONNECTOR"
diff --git a/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx b/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx
index 68b4d84e9..6611fde7d 100644
--- a/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx
+++ b/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx
@@ -28,7 +28,6 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
-import { trackWorkspaceCreated } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
@@ -68,7 +67,8 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia
description: values.description || "",
});
- trackWorkspaceCreated(result.id, values.name);
+ // workspace_created is now emitted server-side (workspaces_routes.py)
+ // so PAT/MCP-created workspaces are also counted.
// Seed the gate's query so it resolves without a loader flash, and
// route straight to onboarding vs. new-chat on the first hop.
diff --git a/surfsense_web/components/settings/earn-credits-content.tsx b/surfsense_web/components/settings/earn-credits-content.tsx
index c14daf099..438cf28d6 100644
--- a/surfsense_web/components/settings/earn-credits-content.tsx
+++ b/surfsense_web/components/settings/earn-credits-content.tsx
@@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
-import { useEffect } from "react";
import { toast } from "sonner";
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
@@ -15,11 +14,7 @@ import { Spinner } from "@/components/ui/spinner";
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
-import {
- trackIncentivePageViewed,
- trackIncentiveTaskClicked,
- trackIncentiveTaskCompleted,
-} from "@/lib/posthog/events";
+import { trackIncentiveTaskClicked } from "@/lib/posthog/events";
import { getWorkspaceIdParam } from "@/lib/route-params";
import { cn } from "@/lib/utils";
@@ -35,9 +30,7 @@ export function EarnCreditsContent() {
const queryClient = useQueryClient();
const workspaceId = getWorkspaceIdParam(params) ?? "";
- useEffect(() => {
- trackIncentivePageViewed();
- }, []);
+ // incentive_page_viewed removed — redundant with $pageview.
const { data, isLoading } = useQuery({
queryKey: ["incentive-tasks"],
@@ -51,13 +44,11 @@ export function EarnCreditsContent() {
const completeMutation = useMutation({
mutationFn: incentiveTasksApiService.completeTask,
- onSuccess: (response, taskType) => {
+ onSuccess: (response) => {
if (response.success) {
toast.success(response.message);
- const task = data?.tasks.find((t) => t.task_type === taskType);
- if (task) {
- trackIncentiveTaskCompleted(taskType, task.credit_micros_reward);
- }
+ // incentive_task_completed is now emitted server-side
+ // (incentive_tasks_routes.complete_task) where credit is granted.
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
}
diff --git a/surfsense_web/components/sources/DocumentUploadTab.tsx b/surfsense_web/components/sources/DocumentUploadTab.tsx
index 278632ecd..9ce27ff11 100644
--- a/surfsense_web/components/sources/DocumentUploadTab.tsx
+++ b/surfsense_web/components/sources/DocumentUploadTab.tsx
@@ -29,11 +29,7 @@ import { Switch } from "@/components/ui/switch";
import type { ProcessingMode } from "@/contracts/types/document.types";
import { useElectronAPI } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service";
-import {
- trackDocumentUploadFailure,
- trackDocumentUploadStarted,
- trackDocumentUploadSuccess,
-} from "@/lib/posthog/events";
+import { trackDocumentUploadStarted } from "@/lib/posthog/events";
import {
getAcceptedFileTypes,
getSupportedExtensions,
@@ -380,13 +376,14 @@ export function DocumentUploadTab({
setUploadProgress(Math.round((uploaded / total) * 100));
}
- trackDocumentUploadSuccess(Number(workspaceId), total);
+ // Ingestion outcome is now emitted server-side
+ // (document_processing_completed/_failed in document_tasks.py); the
+ // upload POST succeeding only means the file was accepted, not processed.
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
setFolderUpload(null);
onSuccess?.();
} catch (error) {
const message = error instanceof Error ? error.message : "Upload failed";
- trackDocumentUploadFailure(Number(workspaceId), message);
toast(t("upload_error"), {
description: `${t("upload_error_desc")}: ${message}`,
});
@@ -421,7 +418,7 @@ export function DocumentUploadTab({
onSuccess: () => {
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
setUploadProgress(100);
- trackDocumentUploadSuccess(Number(workspaceId), files.length);
+ // Ingestion outcome now server-side (document_processing_*).
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
onSuccess?.();
},
@@ -429,7 +426,6 @@ export function DocumentUploadTab({
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
setUploadProgress(0);
const message = error instanceof Error ? error.message : "Upload failed";
- trackDocumentUploadFailure(Number(workspaceId), message);
toast(t("upload_error"), {
description: `${t("upload_error_desc")}: ${message}`,
});
diff --git a/surfsense_web/content/docs/observability.mdx b/surfsense_web/content/docs/observability.mdx
index c6dfb7e3a..1efaff8df 100644
--- a/surfsense_web/content/docs/observability.mdx
+++ b/surfsense_web/content/docs/observability.mdx
@@ -156,6 +156,40 @@ transport.
Celery runtime, and runtime gauges appear within one export interval.
6. Confirm logs emitted inside a traced request show non-zero trace and span IDs.
+## Product Analytics (PostHog)
+
+Separate from OpenTelemetry, the backend can emit server-side product events to
+PostHog. This is the authoritative source for outcome events (chats, document
+ingestion, connector indexing, billing, automations) because it captures traffic
+the browser never sees — MCP clients, personal-access-token scripts, and Celery
+background jobs. It is fully opt-in and mirrors the OTel contract: with
+`POSTHOG_API_KEY` unset, every capture is a silent no-op.
+
+Use the **same** project key as the frontend's `NEXT_PUBLIC_POSTHOG_KEY` so
+server events merge onto the same PostHog persons the web app identifies by user
+id. Add these to `surfsense_backend/.env` (local) or `docker/.env` (production);
+they reach the API, Celery worker, and beat services via `env_file`, so no
+compose changes are needed:
+
+```dotenv
+POSTHOG_API_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+POSTHOG_HOST=https://us.i.posthog.com
+POSTHOG_AI_PRIVACY_MODE=true
+```
+
+`POSTHOG_AI_PRIVACY_MODE` defaults to `true`; set it to `false` only if you want
+LLM prompt and completion bodies shipped to PostHog's AI observability views.
+
+Every backend event is stamped `source=backend` (so it is distinguishable from
+frontend captures), carries `auth_method` / `client` for surface attribution, and
+sends `disable_geoip=true` so the server IP never overwrites a person's real
+location. LangGraph chat turns additionally emit `$ai_generation` / `$ai_span`
+traces via the PostHog LangChain callback handler, keyed by turn and chat id.
+
+Keep event properties low-cardinality. Never attach user content — workspace
+names, connector titles, document titles, prompts, or raw queries — as event
+properties; they carry no aggregation value and are a privacy risk.
+
## Out Of Scope
- Frontend/browser OpenTelemetry.
diff --git a/surfsense_web/hooks/use-run-stream.ts b/surfsense_web/hooks/use-run-stream.ts
index 5d737e059..0ee5c0267 100644
--- a/surfsense_web/hooks/use-run-stream.ts
+++ b/surfsense_web/hooks/use-run-stream.ts
@@ -4,7 +4,6 @@ import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ScraperRunDetail, ScraperRunEvent } from "@/contracts/types/scraper.types";
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
-import { trackWeeklyUser } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export type RunStatus = "idle" | "running" | "success" | "error" | "cancelled";
@@ -110,7 +109,8 @@ export function useRunStream(workspaceId: number) {
try {
const started = await scrapersApiService.runAsync(workspaceId, platform, verb, payload);
runIdRef.current = started.run_id;
- trackWeeklyUser("api_run", workspaceId);
+ // weekly_users removed — WAU is derived server-side from
+ // scraper_run_completed / chat_turn_completed.
setState((s) => ({ ...s, runId: started.run_id }));
void consume(started.run_id, controller.signal);
} catch (e) {
diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts
index 31341a9e6..224f8bb53 100644
--- a/surfsense_web/lib/apis/base-api.service.ts
+++ b/surfsense_web/lib/apis/base-api.service.ts
@@ -39,6 +39,30 @@ function blockRefreshRetry(key: string): void {
refreshRetryBlockedUntil.set(key, Date.now() + REFRESH_RETRY_BLOCK_MS);
}
+/**
+ * Send an API failure to PostHog error tracking. Scoped by the caller to only
+ * 5xx server faults + network outages — 4xx responses are expected behavior.
+ * Lazy-imports posthog-js so an ad-blocker can never break the request path.
+ */
+function captureApiException(error: unknown, url: string, method?: RequestOptions["method"]): void {
+ import("posthog-js")
+ .then(({ default: posthog }) => {
+ posthog.captureException(error, {
+ api_url: url,
+ api_method: method ?? "GET",
+ ...(error instanceof AppError && {
+ status_code: error.status,
+ status_text: error.statusText,
+ error_code: error.code,
+ request_id: error.requestId,
+ }),
+ });
+ })
+ .catch(() => {
+ console.error("Failed to capture exception in PostHog");
+ });
+}
+
export type RequestOptions = {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record;
@@ -281,29 +305,20 @@ class BaseApiService {
throw new AbortedError();
}
if (error instanceof TypeError && !(error instanceof AppError)) {
- throw new NetworkError(
+ const networkError = new NetworkError(
"Unable to connect to the server. Check your internet connection and try again."
);
+ // Network failures are genuine outages worth tracking.
+ captureApiException(networkError, url, options?.method);
+ throw networkError;
}
console.error("Request failed:", JSON.stringify(error));
- if (!(error instanceof AuthenticationError)) {
- import("posthog-js")
- .then(({ default: posthog }) => {
- posthog.captureException(error, {
- api_url: url,
- api_method: options?.method ?? "GET",
- ...(error instanceof AppError && {
- status_code: error.status,
- status_text: error.statusText,
- error_code: error.code,
- request_id: error.requestId,
- }),
- });
- })
- .catch(() => {
- console.error("Failed to capture exception in PostHog");
- });
+ // Only 5xx server faults are unexpected. 4xx (validation, authz, 404)
+ // are expected behavior — capturing them was billable error-tracking
+ // noise. AuthenticationError (401) is a 4xx and stays excluded.
+ if (error instanceof AppError && error.status >= 500) {
+ captureApiException(error, url, options?.method);
}
throw error;
}
diff --git a/surfsense_web/lib/chat/stream-engine/engine.ts b/surfsense_web/lib/chat/stream-engine/engine.ts
index 57ff47f61..34536efc1 100644
--- a/surfsense_web/lib/chat/stream-engine/engine.ts
+++ b/surfsense_web/lib/chat/stream-engine/engine.ts
@@ -58,7 +58,6 @@ import {
import { buildBackendUrl } from "@/lib/env-config";
import {
trackChatBlocked,
- trackChatCreated,
trackChatErrorDetailed,
trackChatMessageSent,
trackChatResponseReceived,
@@ -384,7 +383,8 @@ export async function startNewChat(ctx: EngineContext, message: AppendMessage):
queryClient.setQueryData(cacheKeys.threads.detail(newThread.id), newThread);
queryClient.setQueryData(cacheKeys.threads.messages(newThread.id), { messages: [] });
- trackChatCreated(workspaceId, currentThreadId);
+ // chat_created is now emitted server-side (new_chat_routes.create_thread)
+ // so PAT/MCP-created threads are also counted.
isNewThread = true;
// Update URL silently using browser API (not router.replace) to avoid
diff --git a/surfsense_web/lib/posthog/events.ts b/surfsense_web/lib/posthog/events.ts
index 33d808278..173fd3469 100644
--- a/surfsense_web/lib/posthog/events.ts
+++ b/surfsense_web/lib/posthog/events.ts
@@ -3,24 +3,20 @@ import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat
import { getConnectorTelemetryMeta } from "@/lib/connector-telemetry";
/**
- * PostHog Analytics Event Definitions
+ * PostHog Analytics Event Definitions (frontend)
*
* All capture/identify/reset calls are wrapped in try-catch so that
* ad-blockers that interfere with posthog-js can never break app
* functionality (e.g. the chat flow).
*
- * Events follow a consistent naming convention: category_action
+ * SCOPE: this file now holds only *intent* and client-perceived *UX* events.
+ * Authoritative *outcome* events (resource creation, task/ingestion/indexing
+ * completion, auth success, billing) are emitted server-side in
+ * surfsense_backend/app/observability/analytics.py — they are reliable
+ * regardless of ad-blockers, tab-close, or non-browser (MCP/PAT/OAuth)
+ * clients. Do NOT re-add optimistic outcome captures here; they double-count.
*
- * Categories:
- * - auth: Authentication events
- * - workspace: Search space management
- * - document: Document management
- * - chat: Chat and messaging (authenticated + anonymous)
- * - connector: External connector events (all lifecycle stages)
- * - contact: Contact form events
- * - settings: Settings changes
- * - automation: Automation lifecycle (create/update/delete/trigger/chat)
- * - marketing: Marketing/referral tracking
+ * Events follow a consistent naming convention: category_action
*/
function safeCapture(event: string, properties?: Record) {
@@ -43,17 +39,13 @@ function compact(obj: T): Record {
}
// ============================================
-// AUTH EVENTS
+// AUTH EVENTS (attempts + failures only; successes are server-side)
// ============================================
export function trackLoginAttempt(method: "local" | "google") {
safeCapture("auth_login_attempt", { method });
}
-export function trackLoginSuccess(method: "local" | "google") {
- safeCapture("auth_login_success", { method });
-}
-
export function trackLoginFailure(method: "local" | "google", error?: string) {
safeCapture("auth_login_failure", { method, error });
}
@@ -62,10 +54,6 @@ export function trackRegistrationAttempt() {
safeCapture("auth_registration_attempt");
}
-export function trackRegistrationSuccess() {
- safeCapture("auth_registration_success");
-}
-
export function trackRegistrationFailure(error?: string) {
safeCapture("auth_registration_failure", { error });
}
@@ -75,56 +63,9 @@ export function trackLogout() {
}
// ============================================
-// SEARCH SPACE EVENTS
+// CHAT EVENTS (client-perceived UX)
// ============================================
-export function trackWorkspaceCreated(workspaceId: number, name: string) {
- safeCapture("workspace_created", {
- workspace_id: workspaceId,
- name,
- });
-}
-
-export function trackWorkspaceDeleted(workspaceId: number) {
- safeCapture("workspace_deleted", {
- workspace_id: workspaceId,
- });
-}
-
-export function trackWorkspaceViewed(workspaceId: number) {
- safeCapture("workspace_viewed", {
- workspace_id: workspaceId,
- });
-}
-
-// ============================================
-// ACTIVE-USER (WAU) EVENT
-// ============================================
-
-/**
- * Single signal for active-user counting. Fired whenever a user sends a
- * chat message or starts an API run, so a "weekly unique users on
- * weekly_users" insight in PostHog is our WAU number.
- *
- * ponytail: frontend-only capture — API runs made directly against the
- * backend (PAT/curl, no browser) are not counted. Upgrade path is a
- * server-side capture in the backend if that ever matters.
- */
-export function trackWeeklyUser(source: "chat_message" | "api_run", workspaceId?: number) {
- safeCapture("weekly_users", compact({ source, workspace_id: workspaceId }));
-}
-
-// ============================================
-// CHAT EVENTS
-// ============================================
-
-export function trackChatCreated(workspaceId: number, chatId: number) {
- safeCapture("chat_created", {
- workspace_id: workspaceId,
- chat_id: chatId,
- });
-}
-
export function trackChatMessageSent(
workspaceId: number,
chatId: number,
@@ -141,7 +82,6 @@ export function trackChatMessageSent(
has_mentioned_documents: options?.hasMentionedDocuments ?? false,
message_length: options?.messageLength,
});
- trackWeeklyUser("chat_message", workspaceId);
}
export function trackChatResponseReceived(workspaceId: number, chatId: number) {
@@ -213,6 +153,10 @@ export function trackChatErrorDetailed(
* flow. This is intentionally a separate event from `chat_message_sent`
* so WAU / retention queries on the authenticated event stay clean while
* still giving us visibility into top-of-funnel usage on /free/*.
+ *
+ * Kept frontend-side despite the backend's `anon_chat_turn_completed`: the
+ * frontend anon distinct id is what merges into the person at signup,
+ * powering the anonymous-to-registered conversion funnel.
*/
export function trackAnonymousChatMessageSent(options: {
modelSlug: string;
@@ -229,7 +173,7 @@ export function trackAnonymousChatMessageSent(options: {
}
// ============================================
-// DOCUMENT EVENTS
+// DOCUMENT EVENTS (intent only; ingestion outcome is server-side)
// ============================================
export function trackDocumentUploadStarted(
@@ -244,59 +188,20 @@ export function trackDocumentUploadStarted(
});
}
-export function trackDocumentUploadSuccess(workspaceId: number, fileCount: number) {
- safeCapture("document_upload_success", {
- workspace_id: workspaceId,
- file_count: fileCount,
- });
-}
-
-export function trackDocumentUploadFailure(workspaceId: number, error?: string) {
- safeCapture("document_upload_failure", {
- workspace_id: workspaceId,
- error,
- });
-}
-
-export function trackDocumentDeleted(workspaceId: number, documentId: number) {
- safeCapture("document_deleted", {
- workspace_id: workspaceId,
- document_id: documentId,
- });
-}
-
-export function trackDocumentBulkDeleted(workspaceId: number, count: number) {
- safeCapture("document_bulk_deleted", {
- workspace_id: workspaceId,
- count,
- });
-}
-
-export function trackYouTubeImport(workspaceId: number, url: string) {
- safeCapture("youtube_import_started", {
- workspace_id: workspaceId,
- url,
- });
-}
-
// ============================================
-// CONNECTOR EVENTS (generic lifecycle dispatcher)
+// CONNECTOR EVENTS (setup intent/UX; connected/deleted are server-side)
// ============================================
//
// All connector events go through `trackConnectorEvent`. The connector's
-// human-readable title and its group (oauth/composio/crawler/other) are
-// auto-attached from the shared registry in `connector-constants.ts`, so
-// adding a new connector to that list is the only change required for it
-// to show up correctly in PostHog dashboards.
+// group (oauth/composio/crawler/other) is auto-attached from the shared
+// registry, so adding a new connector to that list is the only change
+// required for it to show up correctly in PostHog dashboards.
export type ConnectorEventStage =
| "setup_started"
| "setup_success"
| "setup_failure"
- | "oauth_initiated"
- | "connected"
- | "deleted"
- | "synced";
+ | "oauth_initiated";
export interface ConnectorEventOptions {
workspaceId?: number | null;
@@ -312,6 +217,9 @@ export interface ConnectorEventOptions {
/**
* Generic connector lifecycle tracker. Every connector analytics event
* should funnel through here so the enrichment stays consistent.
+ *
+ * ``connector_title`` is intentionally NOT sent — it's a display label with
+ * no aggregation value; segment on ``connector_type`` / ``connector_group``.
*/
export function trackConnectorEvent(
stage: ConnectorEventStage,
@@ -327,7 +235,6 @@ export function trackConnectorEvent(
error: options.error,
}),
connector_type: meta.connector_type,
- connector_title: meta.connector_title,
connector_group: meta.connector_group,
is_oauth: meta.is_oauth,
...(options.extra ?? {}),
@@ -365,59 +272,10 @@ export function trackConnectorSetupFailure(
});
}
-export function trackConnectorDeleted(
- workspaceId: number,
- connectorType: string,
- connectorId: number
-) {
- trackConnectorEvent("deleted", connectorType, { workspaceId, connectorId });
-}
-
-export function trackConnectorSynced(
- workspaceId: number,
- connectorType: string,
- connectorId: number
-) {
- trackConnectorEvent("synced", connectorType, { workspaceId, connectorId });
-}
-
-// ============================================
-// SETTINGS EVENTS
-// ============================================
-
-export function trackSettingsViewed(workspaceId: number, section: string) {
- safeCapture("settings_viewed", {
- workspace_id: workspaceId,
- section,
- });
-}
-
-export function trackSettingsUpdated(workspaceId: number, section: string, setting: string) {
- safeCapture("settings_updated", {
- workspace_id: workspaceId,
- section,
- setting,
- });
-}
-
// ============================================
// FEATURE USAGE EVENTS
// ============================================
-export function trackPodcastGenerated(workspaceId: number, chatId: number) {
- safeCapture("podcast_generated", {
- workspace_id: workspaceId,
- chat_id: chatId,
- });
-}
-
-export function trackSourcesTabViewed(workspaceId: number, tab: string) {
- safeCapture("sources_tab_viewed", {
- workspace_id: workspaceId,
- tab,
- });
-}
-
export function trackDesktopDownloadClicked(options: {
os: string;
placement: "sidebar_collapsed" | "sidebar_expanded";
@@ -429,84 +287,7 @@ export function trackDesktopDownloadClicked(options: {
}
// ============================================
-// SEARCH SPACE INVITE EVENTS
-// ============================================
-
-export function trackWorkspaceInviteSent(
- workspaceId: number,
- options?: {
- roleName?: string;
- hasExpiry?: boolean;
- hasMaxUses?: boolean;
- }
-) {
- safeCapture("workspace_invite_sent", {
- workspace_id: workspaceId,
- role_name: options?.roleName,
- has_expiry: options?.hasExpiry ?? false,
- has_max_uses: options?.hasMaxUses ?? false,
- });
-}
-
-export function trackWorkspaceInviteAccepted(
- workspaceId: number,
- workspaceName: string,
- roleName?: string | null
-) {
- safeCapture("workspace_invite_accepted", {
- workspace_id: workspaceId,
- workspace_name: workspaceName,
- role_name: roleName,
- });
-}
-
-export function trackWorkspaceInviteDeclined(workspaceName?: string) {
- safeCapture("workspace_invite_declined", {
- workspace_name: workspaceName,
- });
-}
-
-export function trackWorkspaceUserAdded(
- workspaceId: number,
- workspaceName: string,
- roleName?: string | null
-) {
- safeCapture("workspace_user_added", {
- workspace_id: workspaceId,
- workspace_name: workspaceName,
- role_name: roleName,
- });
-}
-
-export function trackWorkspaceUsersViewed(
- workspaceId: number,
- userCount: number,
- ownerCount: number
-) {
- safeCapture("workspace_users_viewed", {
- workspace_id: workspaceId,
- user_count: userCount,
- owner_count: ownerCount,
- });
-}
-
-// ============================================
-// CONNECTOR CONNECTION EVENTS
-// ============================================
-
-export function trackConnectorConnected(
- workspaceId: number,
- connectorType: string,
- connectorId?: number
-) {
- trackConnectorEvent("connected", connectorType, {
- workspaceId,
- connectorId: connectorId ?? undefined,
- });
-}
-
-// ============================================
-// INDEXING EVENTS
+// INDEXING EVENTS (intent/UX; indexing outcome is server-side)
// ============================================
export function trackIndexWithDateRangeOpened(
@@ -578,20 +359,19 @@ export function trackPeriodicIndexingStarted(
}
// ============================================
-// INCENTIVE TASKS EVENTS
+// SEARCH SPACE INVITE EVENTS (decline is client-only; sent/accepted server-side)
// ============================================
-export function trackIncentivePageViewed() {
- safeCapture("incentive_page_viewed");
-}
-
-export function trackIncentiveTaskCompleted(taskType: string, creditMicrosRewarded: number) {
- safeCapture("incentive_task_completed", {
- task_type: taskType,
- credit_micros_rewarded: creditMicrosRewarded,
+export function trackWorkspaceInviteDeclined(workspaceName?: string) {
+ safeCapture("workspace_invite_declined", {
+ workspace_name: workspaceName,
});
}
+// ============================================
+// INCENTIVE TASKS EVENTS (click intent only; completion is server-side)
+// ============================================
+
export function trackIncentiveTaskClicked(taskType: string) {
safeCapture("incentive_task_clicked", {
task_type: taskType,
@@ -616,83 +396,25 @@ export function trackReferralLanding(refCode: string, landingUrl: string) {
}
// ============================================
-// AUTOMATION EVENTS
+// AUTOMATION EVENTS (failures + chat-builder UX; CRUD outcomes are server-side)
// ============================================
-interface AutomationCreatedProps {
- workspace_id: number;
- automation_id: number;
- task_count?: number;
- trigger_type?: string;
- has_schedule?: boolean;
- chat_model_id?: number;
- image_gen_model_id?: number;
- vision_model_id?: number;
- tags_count?: number;
-}
-
-export function trackAutomationCreated(props: AutomationCreatedProps) {
- safeCapture("automation_created", compact(props));
-}
-
export function trackAutomationCreateFailed(props: { workspace_id?: number; error?: string }) {
safeCapture("automation_create_failed", compact(props));
}
-export function trackAutomationUpdated(props: {
- automation_id: number;
- workspace_id?: number;
- has_definition_change?: boolean;
- has_name_change?: boolean;
- has_description_change?: boolean;
- task_count?: number;
-}) {
- safeCapture("automation_updated", compact(props));
-}
-
-export function trackAutomationStatusChanged(props: {
- automation_id: number;
- workspace_id?: number;
- next_status: string;
-}) {
- safeCapture("automation_status_changed", compact(props));
-}
-
export function trackAutomationUpdateFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_update_failed", compact(props));
}
-export function trackAutomationDeleted(props: { automation_id: number; workspace_id?: number }) {
- safeCapture("automation_deleted", compact(props));
-}
-
export function trackAutomationDeleteFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_delete_failed", compact(props));
}
-export function trackAutomationTriggerAdded(props: {
- automation_id: number;
- trigger_id?: number;
- trigger_type?: string;
- enabled?: boolean;
- has_cron?: boolean;
-}) {
- safeCapture("automation_trigger_added", compact(props));
-}
-
export function trackAutomationTriggerAddFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_trigger_add_failed", compact(props));
}
-export function trackAutomationTriggerUpdated(props: {
- automation_id: number;
- trigger_id: number;
- change?: "enabled" | "params" | "other";
- enabled?: boolean;
-}) {
- safeCapture("automation_trigger_updated", compact(props));
-}
-
export function trackAutomationTriggerUpdateFailed(props: {
automation_id: number;
trigger_id: number;
@@ -701,13 +423,6 @@ export function trackAutomationTriggerUpdateFailed(props: {
safeCapture("automation_trigger_update_failed", compact(props));
}
-export function trackAutomationTriggerRemoved(props: {
- automation_id: number;
- trigger_id: number;
-}) {
- safeCapture("automation_trigger_removed", compact(props));
-}
-
export function trackAutomationTriggerRemoveFailed(props: {
automation_id: number;
trigger_id: number;
From 4e39b411bef618b3b3009cc573204da664d0f476 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 23 Jul 2026 17:02:06 +0530
Subject: [PATCH 151/217] refactor: simplify docstring in capability registry
for clarity
---
surfsense_backend/app/capabilities/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/surfsense_backend/app/capabilities/__init__.py b/surfsense_backend/app/capabilities/__init__.py
index 9c97ef59a..dfdab630b 100644
--- a/surfsense_backend/app/capabilities/__init__.py
+++ b/surfsense_backend/app/capabilities/__init__.py
@@ -1,4 +1,4 @@
-"""Scraper capability registry — typed, stateless verbs. See plans/backend/04-capabilities.md."""
+"""Scraper capability registry — typed, stateless verbs."""
from __future__ import annotations
From 797dd2df47bcd279a6c4e866cdeb0b2041caac88 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 18:52:32 +0200
Subject: [PATCH 152/217] feat: add HttpUrlStr shared URL validator
---
.../app/capabilities/core/validation.py | 24 ++++++++++++++
.../tests/unit/capabilities/core/__init__.py | 0
.../unit/capabilities/core/test_validation.py | 32 +++++++++++++++++++
3 files changed, 56 insertions(+)
create mode 100644 surfsense_backend/app/capabilities/core/validation.py
create mode 100644 surfsense_backend/tests/unit/capabilities/core/__init__.py
create mode 100644 surfsense_backend/tests/unit/capabilities/core/test_validation.py
diff --git a/surfsense_backend/app/capabilities/core/validation.py b/surfsense_backend/app/capabilities/core/validation.py
new file mode 100644
index 000000000..3e0885488
--- /dev/null
+++ b/surfsense_backend/app/capabilities/core/validation.py
@@ -0,0 +1,24 @@
+"""Shared Pydantic field types for capability I/O schemas."""
+
+from __future__ import annotations
+
+from typing import Annotated
+from urllib.parse import urlsplit
+
+import validators
+from pydantic import AfterValidator
+from pydantic_core import PydanticCustomError
+
+_HTTP_SCHEMES = frozenset({"http", "https"})
+
+
+def _validate_http_url(value: str) -> str:
+ """Accept only well-formed http(s) URLs, returned trimmed and unchanged."""
+ url = value.strip()
+ if not validators.url(url) or urlsplit(url).scheme.lower() not in _HTTP_SCHEMES:
+ raise PydanticCustomError("http_url", "must be a valid http(s) URL")
+ return url
+
+
+HttpUrlStr = Annotated[str, AfterValidator(_validate_http_url)]
+"""A request URL validated as http(s) and kept as ``str`` (no normalization)."""
diff --git a/surfsense_backend/tests/unit/capabilities/core/__init__.py b/surfsense_backend/tests/unit/capabilities/core/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/surfsense_backend/tests/unit/capabilities/core/test_validation.py b/surfsense_backend/tests/unit/capabilities/core/test_validation.py
new file mode 100644
index 000000000..03db512ba
--- /dev/null
+++ b/surfsense_backend/tests/unit/capabilities/core/test_validation.py
@@ -0,0 +1,32 @@
+"""``HttpUrlStr`` boundary type: accept well-formed http(s) URLs, reject the rest."""
+
+from __future__ import annotations
+
+import pytest
+from pydantic import BaseModel, ValidationError
+
+from app.capabilities.core.validation import HttpUrlStr
+
+pytestmark = pytest.mark.unit
+
+
+class _Model(BaseModel):
+ urls: list[HttpUrlStr]
+
+
+def test_accepts_http_and_https_urls_unchanged() -> None:
+ model = _Model(urls=["https://example.com/path?q=1", "http://a.co"])
+ assert model.urls == ["https://example.com/path?q=1", "http://a.co"]
+
+
+def test_trims_surrounding_whitespace() -> None:
+ assert _Model(urls=[" https://example.com "]).urls == ["https://example.com"]
+
+
+@pytest.mark.parametrize(
+ "bad",
+ ["not-a-url", "example.com", "ftp://example.com", "http://", "javascript:alert(1)"],
+)
+def test_rejects_malformed_or_non_http_urls(bad: str) -> None:
+ with pytest.raises(ValidationError):
+ _Model(urls=[bad])
From a123c95703b29f5ebfdf35d3ce0b187fa4367b6d Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 18:52:32 +0200
Subject: [PATCH 153/217] feat: validate web.crawl start URLs
---
surfsense_backend/app/capabilities/web/crawl/schemas.py | 4 +++-
.../tests/unit/capabilities/web/crawl/test_schemas.py | 5 +++++
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/surfsense_backend/app/capabilities/web/crawl/schemas.py b/surfsense_backend/app/capabilities/web/crawl/schemas.py
index d6c9c38a3..30e8ff6ae 100644
--- a/surfsense_backend/app/capabilities/web/crawl/schemas.py
+++ b/surfsense_backend/app/capabilities/web/crawl/schemas.py
@@ -18,6 +18,8 @@ from typing import Literal
from pydantic import BaseModel, Field
+from app.capabilities.core.validation import HttpUrlStr
+
MAX_START_URLS = 20
"""Per-call cap on seed URLs: bounds a synchronous request's fan-out (05)."""
@@ -29,7 +31,7 @@ MAX_CRAWL_PAGES = 200
class CrawlInput(BaseModel):
- startUrls: list[str] = Field(
+ startUrls: list[HttpUrlStr] = Field(
min_length=1,
max_length=MAX_START_URLS,
description=(
diff --git a/surfsense_backend/tests/unit/capabilities/web/crawl/test_schemas.py b/surfsense_backend/tests/unit/capabilities/web/crawl/test_schemas.py
index f7b9a2b39..080dda2b2 100644
--- a/surfsense_backend/tests/unit/capabilities/web/crawl/test_schemas.py
+++ b/surfsense_backend/tests/unit/capabilities/web/crawl/test_schemas.py
@@ -20,6 +20,11 @@ def test_requires_at_least_one_start_url() -> None:
CrawlInput(startUrls=[])
+def test_rejects_malformed_start_url() -> None:
+ with pytest.raises(ValidationError):
+ CrawlInput(startUrls=["not-a-url"])
+
+
def test_camelcase_fields_and_defaults() -> None:
model = CrawlInput(startUrls=["https://e.com"])
assert model.startUrls == ["https://e.com"]
From 1996d3e54d194fee50ddb0d22f64e8b5e91703bb Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 18:52:32 +0200
Subject: [PATCH 154/217] feat: validate amazon scrape URLs
---
surfsense_backend/app/capabilities/amazon/scrape/schemas.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/surfsense_backend/app/capabilities/amazon/scrape/schemas.py b/surfsense_backend/app/capabilities/amazon/scrape/schemas.py
index e510e2eea..b2a847058 100644
--- a/surfsense_backend/app/capabilities/amazon/scrape/schemas.py
+++ b/surfsense_backend/app/capabilities/amazon/scrape/schemas.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from pydantic import BaseModel, Field, model_validator
+from app.capabilities.core.validation import HttpUrlStr
from app.proprietary.platforms.amazon import ProductItem
MAX_AMAZON_SOURCES = 20
@@ -13,7 +14,7 @@ MAX_AMAZON_RESULTS = 1000
class ScrapeInput(BaseModel):
"""Agent-facing controls for public product discovery and enrichment."""
- urls: list[str] = Field(default_factory=list, max_length=MAX_AMAZON_SOURCES)
+ urls: list[HttpUrlStr] = Field(default_factory=list, max_length=MAX_AMAZON_SOURCES)
search_terms: list[str] = Field(default_factory=list, max_length=MAX_AMAZON_SOURCES)
max_items: int = Field(default=10, ge=1, le=100)
domain: str = Field(
From 4e4b9236c3f02f658c5b852011a7fdde469bfcf3 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 18:52:32 +0200
Subject: [PATCH 155/217] feat: validate walmart scrape and reviews URLs
---
surfsense_backend/app/capabilities/walmart/reviews/schemas.py | 3 ++-
surfsense_backend/app/capabilities/walmart/scrape/schemas.py | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/surfsense_backend/app/capabilities/walmart/reviews/schemas.py b/surfsense_backend/app/capabilities/walmart/reviews/schemas.py
index faf56ed5b..bd1dc73d9 100644
--- a/surfsense_backend/app/capabilities/walmart/reviews/schemas.py
+++ b/surfsense_backend/app/capabilities/walmart/reviews/schemas.py
@@ -11,13 +11,14 @@ from typing import Literal
from pydantic import BaseModel, Field, model_validator
+from app.capabilities.core.validation import HttpUrlStr
from app.proprietary.platforms.walmart import ReviewItem
MAX_WALMART_REVIEW_SOURCES = 20
class ReviewsInput(BaseModel):
- urls: list[str] = Field(
+ urls: list[HttpUrlStr] = Field(
default_factory=list,
max_length=MAX_WALMART_REVIEW_SOURCES,
description=(
diff --git a/surfsense_backend/app/capabilities/walmart/scrape/schemas.py b/surfsense_backend/app/capabilities/walmart/scrape/schemas.py
index 2d42dc371..7faaabd1d 100644
--- a/surfsense_backend/app/capabilities/walmart/scrape/schemas.py
+++ b/surfsense_backend/app/capabilities/walmart/scrape/schemas.py
@@ -6,6 +6,7 @@ from urllib.parse import quote_plus
from pydantic import BaseModel, Field, model_validator
+from app.capabilities.core.validation import HttpUrlStr
from app.proprietary.platforms.walmart import ProductItem
MAX_WALMART_SOURCES = 20
@@ -15,7 +16,7 @@ MAX_WALMART_RESULTS = 1000
class ScrapeInput(BaseModel):
"""Agent-facing controls for public Walmart product discovery and enrichment."""
- urls: list[str] = Field(default_factory=list, max_length=MAX_WALMART_SOURCES)
+ urls: list[HttpUrlStr] = Field(default_factory=list, max_length=MAX_WALMART_SOURCES)
search_terms: list[str] = Field(
default_factory=list, max_length=MAX_WALMART_SOURCES
)
From babefecee788b2580ece89ae0e3e824921ef5d43 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 18:52:32 +0200
Subject: [PATCH 156/217] feat: validate youtube scrape and comments URLs
---
surfsense_backend/app/capabilities/youtube/comments/schemas.py | 3 ++-
surfsense_backend/app/capabilities/youtube/scrape/schemas.py | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/surfsense_backend/app/capabilities/youtube/comments/schemas.py b/surfsense_backend/app/capabilities/youtube/comments/schemas.py
index a9da5b7a2..ecfa80026 100644
--- a/surfsense_backend/app/capabilities/youtube/comments/schemas.py
+++ b/surfsense_backend/app/capabilities/youtube/comments/schemas.py
@@ -10,6 +10,7 @@ from typing import Literal
from pydantic import BaseModel, Field
+from app.capabilities.core.validation import HttpUrlStr
from app.proprietary.platforms.youtube import CommentItem
MAX_COMMENT_VIDEOS = 20
@@ -17,7 +18,7 @@ MAX_COMMENT_VIDEOS = 20
class CommentsInput(BaseModel):
- urls: list[str] = Field(
+ urls: list[HttpUrlStr] = Field(
min_length=1,
max_length=MAX_COMMENT_VIDEOS,
description="YouTube video URLs to fetch comments (and replies) for (1-20).",
diff --git a/surfsense_backend/app/capabilities/youtube/scrape/schemas.py b/surfsense_backend/app/capabilities/youtube/scrape/schemas.py
index 4d80769b6..63a1df060 100644
--- a/surfsense_backend/app/capabilities/youtube/scrape/schemas.py
+++ b/surfsense_backend/app/capabilities/youtube/scrape/schemas.py
@@ -10,6 +10,7 @@ from __future__ import annotations
from pydantic import BaseModel, Field, model_validator
+from app.capabilities.core.validation import HttpUrlStr
from app.proprietary.platforms.youtube import VideoItem
MAX_YOUTUBE_SOURCES = 20
@@ -17,7 +18,7 @@ MAX_YOUTUBE_SOURCES = 20
class ScrapeInput(BaseModel):
- urls: list[str] = Field(
+ urls: list[HttpUrlStr] = Field(
default_factory=list,
max_length=MAX_YOUTUBE_SOURCES,
description=(
From 96f05991eb73f861d519fbfa0c4abef2eb22fc48 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 18:52:38 +0200
Subject: [PATCH 157/217] feat: validate reddit scrape URLs
---
surfsense_backend/app/capabilities/reddit/scrape/schemas.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/surfsense_backend/app/capabilities/reddit/scrape/schemas.py b/surfsense_backend/app/capabilities/reddit/scrape/schemas.py
index 0c2da536e..9e0143ba7 100644
--- a/surfsense_backend/app/capabilities/reddit/scrape/schemas.py
+++ b/surfsense_backend/app/capabilities/reddit/scrape/schemas.py
@@ -10,6 +10,7 @@ from __future__ import annotations
from pydantic import BaseModel, Field, model_validator
+from app.capabilities.core.validation import HttpUrlStr
from app.proprietary.platforms.reddit import RedditItem
from app.proprietary.platforms.reddit.schemas import RedditSort, RedditTime
@@ -21,7 +22,7 @@ MAX_REDDIT_ITEMS = 100
class ScrapeInput(BaseModel):
- urls: list[str] = Field(
+ urls: list[HttpUrlStr] = Field(
default_factory=list,
max_length=MAX_REDDIT_SOURCES,
description=(
From a5ccd403879eaa23c91680e9604caf0166005758 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 18:52:39 +0200
Subject: [PATCH 158/217] feat: validate tiktok scrape and comments URLs
---
surfsense_backend/app/capabilities/tiktok/comments/schemas.py | 3 ++-
surfsense_backend/app/capabilities/tiktok/scrape/schemas.py | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/surfsense_backend/app/capabilities/tiktok/comments/schemas.py b/surfsense_backend/app/capabilities/tiktok/comments/schemas.py
index f3c68d445..61ae0c756 100644
--- a/surfsense_backend/app/capabilities/tiktok/comments/schemas.py
+++ b/surfsense_backend/app/capabilities/tiktok/comments/schemas.py
@@ -10,6 +10,7 @@ from __future__ import annotations
from pydantic import BaseModel, Field
+from app.capabilities.core.validation import HttpUrlStr
from app.capabilities.tiktok.scrape.schemas import (
MAX_TIKTOK_ITEMS,
MAX_TIKTOK_SOURCES,
@@ -18,7 +19,7 @@ from app.proprietary.platforms.tiktok import CommentItem
class CommentsInput(BaseModel):
- video_urls: list[str] = Field(
+ video_urls: list[HttpUrlStr] = Field(
min_length=1,
max_length=MAX_TIKTOK_SOURCES,
description="TikTok video URLs (/@/video/) to pull comments from.",
diff --git a/surfsense_backend/app/capabilities/tiktok/scrape/schemas.py b/surfsense_backend/app/capabilities/tiktok/scrape/schemas.py
index ac3792712..b139e6e49 100644
--- a/surfsense_backend/app/capabilities/tiktok/scrape/schemas.py
+++ b/surfsense_backend/app/capabilities/tiktok/scrape/schemas.py
@@ -12,6 +12,7 @@ from __future__ import annotations
from pydantic import BaseModel, Field, model_validator
+from app.capabilities.core.validation import HttpUrlStr
from app.proprietary.platforms.tiktok import TikTokVideoItem
MAX_TIKTOK_SOURCES = 20
@@ -22,7 +23,7 @@ MAX_TIKTOK_ITEMS = 100
class ScrapeInput(BaseModel):
- urls: list[str] = Field(
+ urls: list[HttpUrlStr] = Field(
default_factory=list,
max_length=MAX_TIKTOK_SOURCES,
description=(
From 9e04eb2b892ef621f60d8d1389b2eeb1ebed3cca Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 18:52:39 +0200
Subject: [PATCH 159/217] feat: validate google maps scrape and reviews URLs
---
.../app/capabilities/google_maps/reviews/schemas.py | 3 ++-
.../app/capabilities/google_maps/scrape/schemas.py | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/surfsense_backend/app/capabilities/google_maps/reviews/schemas.py b/surfsense_backend/app/capabilities/google_maps/reviews/schemas.py
index 1868765e9..26cdad29e 100644
--- a/surfsense_backend/app/capabilities/google_maps/reviews/schemas.py
+++ b/surfsense_backend/app/capabilities/google_maps/reviews/schemas.py
@@ -10,6 +10,7 @@ from typing import Literal
from pydantic import BaseModel, Field, model_validator
+from app.capabilities.core.validation import HttpUrlStr
from app.proprietary.platforms.google_maps import ReviewItem
MAX_MAPS_REVIEW_SOURCES = 20
@@ -17,7 +18,7 @@ MAX_MAPS_REVIEW_SOURCES = 20
class ReviewsInput(BaseModel):
- urls: list[str] = Field(
+ urls: list[HttpUrlStr] = Field(
default_factory=list,
max_length=MAX_MAPS_REVIEW_SOURCES,
description=(
diff --git a/surfsense_backend/app/capabilities/google_maps/scrape/schemas.py b/surfsense_backend/app/capabilities/google_maps/scrape/schemas.py
index 95833c8f7..7cf1ca6a1 100644
--- a/surfsense_backend/app/capabilities/google_maps/scrape/schemas.py
+++ b/surfsense_backend/app/capabilities/google_maps/scrape/schemas.py
@@ -10,6 +10,7 @@ from __future__ import annotations
from pydantic import BaseModel, Field, model_validator
+from app.capabilities.core.validation import HttpUrlStr
from app.proprietary.platforms.google_maps import PlaceItem
MAX_MAPS_SOURCES = 20
@@ -26,7 +27,7 @@ class ScrapeInput(BaseModel):
"(at least one is required). Pair with location to scope a search."
),
)
- urls: list[str] = Field(
+ urls: list[HttpUrlStr] = Field(
default_factory=list,
max_length=MAX_MAPS_SOURCES,
description=(
From 574b40be40282e3fc8bb49209ca89bb903f7081a Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 18:52:39 +0200
Subject: [PATCH 160/217] feat: validate indeed scrape URLs
---
surfsense_backend/app/capabilities/indeed/scrape/schemas.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/surfsense_backend/app/capabilities/indeed/scrape/schemas.py b/surfsense_backend/app/capabilities/indeed/scrape/schemas.py
index 02e29100d..7c5dacb59 100644
--- a/surfsense_backend/app/capabilities/indeed/scrape/schemas.py
+++ b/surfsense_backend/app/capabilities/indeed/scrape/schemas.py
@@ -10,6 +10,7 @@ from __future__ import annotations
from pydantic import BaseModel, Field, model_validator
+from app.capabilities.core.validation import HttpUrlStr
from app.proprietary.platforms.indeed_jobs import IndeedItem
from app.proprietary.platforms.indeed_jobs.schemas import (
IndeedJobType,
@@ -26,7 +27,7 @@ MAX_INDEED_ITEMS = 100
class ScrapeInput(BaseModel):
- urls: list[str] = Field(
+ urls: list[HttpUrlStr] = Field(
default_factory=list,
max_length=MAX_INDEED_SOURCES,
description=(
From 4dddd55512aba32525287f0d90c2448f87823750 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 18:52:39 +0200
Subject: [PATCH 161/217] feat: return structured field errors on 422
---
surfsense_backend/app/app.py | 51 +++++++++++++++++++++++++-----------
1 file changed, 35 insertions(+), 16 deletions(-)
diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py
index dde2ce7fa..de3b066f6 100644
--- a/surfsense_backend/app/app.py
+++ b/surfsense_backend/app/app.py
@@ -8,6 +8,7 @@ from collections import defaultdict
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from threading import Lock
+from typing import Any
import redis
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
@@ -94,17 +95,21 @@ def _build_error_response(
code: str = "INTERNAL_ERROR",
request_id: str = "",
extra_headers: dict[str, str] | None = None,
+ fields: list[dict[str, Any]] | None = None,
) -> JSONResponse:
"""Build the standardized error envelope (new ``error`` + legacy ``detail``)."""
+ error: dict[str, Any] = {
+ "code": code,
+ "message": message,
+ "status": status_code,
+ "request_id": request_id,
+ "timestamp": datetime.now(UTC).isoformat(),
+ "report_url": ISSUES_URL,
+ }
+ if fields:
+ error["fields"] = fields
body = {
- "error": {
- "code": code,
- "message": message,
- "status": status_code,
- "request_id": request_id,
- "timestamp": datetime.now(UTC).isoformat(),
- "report_url": ISSUES_URL,
- },
+ "error": error,
"detail": message,
}
headers = {"X-Request-ID": request_id}
@@ -222,16 +227,30 @@ def _http_exception_handler(request: Request, exc: HTTPException) -> JSONRespons
def _validation_error_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
- """Return 422 with field-level detail in the standard envelope."""
+ """Return 422 with field-level detail in the standard envelope.
+
+ ``error.fields`` carries each failure's location path and message so clients
+ can attach errors to the offending input; ``message`` is the flat summary.
+ """
rid = _get_request_id(request)
- fields = []
- for err in exc.errors():
- loc = " -> ".join(str(part) for part in err.get("loc", []))
- fields.append(f"{loc}: {err.get('msg', 'invalid')}")
- message = (
- f"Validation failed: {'; '.join(fields)}" if fields else "Validation failed."
+ fields = [
+ {
+ "loc": [str(part) for part in err.get("loc", ())],
+ # Drop pydantic's "Value error, " prefix so messages read for humans.
+ "msg": str(err.get("msg", "invalid")).removeprefix("Value error, "),
+ }
+ for err in exc.errors()
+ ]
+
+ def _segment(field: dict[str, Any]) -> str:
+ loc = " -> ".join(field["loc"])
+ return f"{loc}: {field['msg']}" if loc else field["msg"]
+
+ summary = "; ".join(_segment(f) for f in fields)
+ message = f"Validation failed: {summary}" if fields else "Validation failed."
+ return _build_error_response(
+ 422, message, code="VALIDATION_ERROR", request_id=rid, fields=fields
)
- return _build_error_response(422, message, code="VALIDATION_ERROR", request_id=rid)
def _unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
From 6330d5a789d53b53540f167273f93d384206631d Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 18:52:50 +0200
Subject: [PATCH 162/217] fix: reject spoofed non-YouTube URLs
---
.../platforms/youtube/url_resolver.py | 19 +++++++++++++------
.../unit/platforms/youtube/test_parsers.py | 18 ++++++++++++++++--
2 files changed, 29 insertions(+), 8 deletions(-)
diff --git a/surfsense_backend/app/proprietary/platforms/youtube/url_resolver.py b/surfsense_backend/app/proprietary/platforms/youtube/url_resolver.py
index 0e68425a9..aa7df36e9 100644
--- a/surfsense_backend/app/proprietary/platforms/youtube/url_resolver.py
+++ b/surfsense_backend/app/proprietary/platforms/youtube/url_resolver.py
@@ -15,6 +15,9 @@ ResolvedKind = Literal["video", "channel", "playlist", "hashtag", "search"]
_PLAYLIST_ID_RE = re.compile(r"[?&]list=([\w-]+)")
+_YOUTUBE_HOSTS = frozenset({"www.youtube.com", "youtube.com", "m.youtube.com"})
+"""Hosts whose page paths (/@, /channel, /playlist, ...) are trusted as YouTube."""
+
@dataclass(frozen=True)
class ResolvedUrl:
@@ -29,7 +32,7 @@ def get_youtube_video_id(url: str) -> str | None:
hostname = parsed.hostname or ""
if hostname == "youtu.be":
return parsed.path[1:] or None
- if hostname in ("www.youtube.com", "youtube.com", "m.youtube.com"):
+ if hostname in _YOUTUBE_HOSTS:
if parsed.path == "/watch":
return parse_qs(parsed.query).get("v", [None])[0]
for prefix in ("/embed/", "/v/", "/shorts/"):
@@ -41,17 +44,21 @@ def get_youtube_video_id(url: str) -> str | None:
def resolve_url(url: str) -> ResolvedUrl | None:
"""Classify a YouTube URL into a scrape job, or ``None`` if unrecognized."""
parsed = urlparse(url)
+ hostname = (parsed.hostname or "").lower()
path = parsed.path or ""
- # Shorts are videos with their own path.
- if "/shorts/" in path:
- vid = path.split("/shorts/")[1].split("/")[0]
- return ResolvedUrl("video", vid, url) if vid else None
-
+ # Videos: watch / youtu.be / embed / shorts. get_youtube_video_id validates
+ # the host itself, so this also covers youtu.be short links.
video_id = get_youtube_video_id(url)
if video_id:
return ResolvedUrl("video", video_id, url)
+ # Every remaining shape is a youtube.com page keyed off its path. Require a
+ # YouTube host so a well-formed non-YouTube URL (e.g. https://evil.com/@x)
+ # is rejected instead of misclassified as a channel by its path alone.
+ if hostname not in _YOUTUBE_HOSTS:
+ return None
+
# Playlist (either a /playlist page or any URL carrying ?list=).
playlist_match = _PLAYLIST_ID_RE.search(url)
if path.startswith("/playlist") and playlist_match:
diff --git a/surfsense_backend/tests/unit/platforms/youtube/test_parsers.py b/surfsense_backend/tests/unit/platforms/youtube/test_parsers.py
index 55f591c63..6b7b460a9 100644
--- a/surfsense_backend/tests/unit/platforms/youtube/test_parsers.py
+++ b/surfsense_backend/tests/unit/platforms/youtube/test_parsers.py
@@ -946,8 +946,22 @@ def test_resolve_url(url, kind, value):
assert resolved.value == value
-def test_resolve_url_unrecognized():
- assert resolve_url("https://example.com/foo") is None
+@pytest.mark.parametrize(
+ "url",
+ [
+ "https://example.com/foo",
+ # Well-formed non-YouTube URLs whose path mimics a YouTube page must be
+ # rejected, not misclassified by path alone (host-spoof guard).
+ "https://evil.com/@Apify",
+ "https://evil.com/channel/UC123456789abc",
+ "https://evil.com/shorts/abc123",
+ "https://evil.com/playlist?list=PL123",
+ "https://evil.com/hashtag/tech",
+ "https://evil.com/results?search_query=web+scraping",
+ ],
+)
+def test_resolve_url_unrecognized(url):
+ assert resolve_url(url) is None
# --- optional: exercise captured real fixtures if present --------------------
From 18f5d30e6105b29b04474bb1dbe95062320558e5 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 18:52:50 +0200
Subject: [PATCH 163/217] feat: carry validation field errors in AppError
---
surfsense_web/lib/apis/base-api.service.ts | 11 +++++++++--
surfsense_web/lib/error.ts | 8 ++++++++
2 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts
index 31341a9e6..872c885d9 100644
--- a/surfsense_web/lib/apis/base-api.service.ts
+++ b/surfsense_web/lib/apis/base-api.service.ts
@@ -10,6 +10,7 @@ import {
AuthorizationError,
NetworkError,
NotFoundError,
+ type ValidationFieldError,
} from "../error";
enum ResponseType {
@@ -165,6 +166,9 @@ class BaseApiService {
const requestId: string | undefined =
envelope?.request_id ?? response.headers.get("X-Request-ID") ?? undefined;
const reportUrl: string | undefined = envelope?.report_url;
+ const validationFields: ValidationFieldError[] | undefined = Array.isArray(envelope?.fields)
+ ? envelope.fields
+ : undefined;
// Handle 401 - try to refresh token first (only once)
if (response.status === 401) {
@@ -209,8 +213,8 @@ class BaseApiService {
response.status,
response.statusText
);
- default:
- throw new AppError(
+ default: {
+ const appError = new AppError(
errorMessage || "Something went wrong",
response.status,
response.statusText,
@@ -218,6 +222,9 @@ class BaseApiService {
requestId,
reportUrl
);
+ appError.fields = validationFields;
+ throw appError;
+ }
}
}
refreshRetryBlockedUntil.delete(getRefreshRetryKey(mergedOptions.method, url));
diff --git a/surfsense_web/lib/error.ts b/surfsense_web/lib/error.ts
index 10cb3706e..8c87007b8 100644
--- a/surfsense_web/lib/error.ts
+++ b/surfsense_web/lib/error.ts
@@ -1,11 +1,19 @@
export const SURFSENSE_ISSUES_URL = "https://github.com/MODSetter/SurfSense/issues";
+/** One field-level failure from a 422 validation response. */
+export interface ValidationFieldError {
+ loc: string[];
+ msg: string;
+}
+
export class AppError extends Error {
status?: number;
statusText?: string;
code?: string;
requestId?: string;
reportUrl?: string;
+ /** Per-field failures from a 422 response, keyed by their location path. */
+ fields?: ValidationFieldError[];
constructor(
message: string,
status?: number,
From d7f89bb3fd0f3b32e4f3a6906540848901cf5d48 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 18:52:50 +0200
Subject: [PATCH 164/217] feat: map 422 field errors for playground forms
---
.../lib/playground/field-errors.test.ts | 34 +++++++++++++++++++
surfsense_web/lib/playground/field-errors.ts | 18 ++++++++++
2 files changed, 52 insertions(+)
create mode 100644 surfsense_web/lib/playground/field-errors.test.ts
create mode 100644 surfsense_web/lib/playground/field-errors.ts
diff --git a/surfsense_web/lib/playground/field-errors.test.ts b/surfsense_web/lib/playground/field-errors.test.ts
new file mode 100644
index 000000000..273cf1cc3
--- /dev/null
+++ b/surfsense_web/lib/playground/field-errors.test.ts
@@ -0,0 +1,34 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { AppError } from "@/lib/error";
+import { fieldErrorsFromError } from "./field-errors";
+
+// Run with: pnpm exec tsx --test lib/playground/field-errors.test.ts
+
+function validationError(fields: { loc: string[]; msg: string }[]): AppError {
+ const error = new AppError("Validation failed", 422, "Unprocessable Entity", "VALIDATION_ERROR");
+ error.fields = fields;
+ return error;
+}
+
+test("maps a body field failure to its top-level field name", () => {
+ const error = validationError([
+ { loc: ["body", "startUrls", "0"], msg: "must be a valid http(s) URL" },
+ ]);
+ assert.deepEqual(fieldErrorsFromError(error), {
+ startUrls: "must be a valid http(s) URL",
+ });
+});
+
+test("keeps the first failure per field", () => {
+ const error = validationError([
+ { loc: ["body", "urls", "0"], msg: "first" },
+ { loc: ["body", "urls", "3"], msg: "second" },
+ ]);
+ assert.deepEqual(fieldErrorsFromError(error), { urls: "first" });
+});
+
+test("returns nothing for non-AppError or errors without fields", () => {
+ assert.deepEqual(fieldErrorsFromError(new Error("boom")), {});
+ assert.deepEqual(fieldErrorsFromError(new AppError("no fields", 500)), {});
+});
diff --git a/surfsense_web/lib/playground/field-errors.ts b/surfsense_web/lib/playground/field-errors.ts
new file mode 100644
index 000000000..668d2d9c1
--- /dev/null
+++ b/surfsense_web/lib/playground/field-errors.ts
@@ -0,0 +1,18 @@
+import { AppError } from "@/lib/error";
+
+/** Map a 422 response's field failures to ``{ fieldName: message }`` for the form. */
+export function fieldErrorsFromError(error: unknown): Record {
+ if (!(error instanceof AppError) || !error.fields) return {};
+ const errors: Record = {};
+ for (const { loc, msg } of error.fields) {
+ const name = topLevelField(loc);
+ if (name && !errors[name]) errors[name] = msg;
+ }
+ return errors;
+}
+
+/** The input field a location path points at, dropping the ``body`` request root. */
+function topLevelField(loc: string[]): string | undefined {
+ const path = loc[0] === "body" ? loc.slice(1) : loc;
+ return path[0];
+}
From 1ad8efb6587b64fe2716b1d1c4e13379bd891d49 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 18:52:50 +0200
Subject: [PATCH 165/217] feat: add per-platform URL hints
---
.../lib/playground/url-hints.test.ts | 52 +++++++++++
surfsense_web/lib/playground/url-hints.ts | 90 +++++++++++++++++++
2 files changed, 142 insertions(+)
create mode 100644 surfsense_web/lib/playground/url-hints.test.ts
create mode 100644 surfsense_web/lib/playground/url-hints.ts
diff --git a/surfsense_web/lib/playground/url-hints.test.ts b/surfsense_web/lib/playground/url-hints.test.ts
new file mode 100644
index 000000000..84570c8e9
--- /dev/null
+++ b/surfsense_web/lib/playground/url-hints.test.ts
@@ -0,0 +1,52 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { urlFieldWarning, urlFieldWarnings } from "./url-hints";
+
+// Run with: pnpm exec tsx --test lib/playground/url-hints.test.ts
+
+test("accepts a matching platform URL (no warning)", () => {
+ assert.equal(urlFieldWarning("youtube", "urls", "https://www.youtube.com/watch?v=x"), undefined);
+ assert.equal(urlFieldWarning("youtube", "urls", "https://youtu.be/x"), undefined);
+});
+
+test("flags a well-formed URL for the wrong platform", () => {
+ assert.equal(
+ urlFieldWarning("youtube", "urls", "https://google.com"),
+ "Not a YouTube URL: https://google.com"
+ );
+});
+
+test("flags a malformed / scheme-less URL", () => {
+ assert.equal(
+ urlFieldWarning("reddit", "urls", "reddit.com/r/python"),
+ "Not a Reddit URL: reddit.com/r/python"
+ );
+});
+
+test("lists every offending line, ignoring blanks", () => {
+ const value = "https://www.tiktok.com/@a\n\n https://google.com \nnot-a-url";
+ assert.equal(
+ urlFieldWarning("tiktok", "urls", value),
+ "Not a TikTok URL: https://google.com, not-a-url"
+ );
+});
+
+test("matches marketplace/shortlink host variants", () => {
+ assert.equal(urlFieldWarning("amazon", "urls", "https://www.amazon.fr/dp/B0"), undefined);
+ assert.equal(urlFieldWarning("amazon", "urls", "https://a.co/d/xyz"), undefined);
+ assert.equal(urlFieldWarning("google_maps", "urls", "https://maps.app.goo.gl/xyz"), undefined);
+});
+
+test("only inspects known URL fields, and skips platforms without a rule", () => {
+ // search_terms is not a URL field — never warned.
+ assert.equal(urlFieldWarning("amazon", "search_terms", "laptop"), undefined);
+ // instagram has no rule (its urls accept bare @handles).
+ assert.equal(urlFieldWarning("instagram", "urls", "natgeo"), undefined);
+});
+
+test("collects per-field warnings across the form values", () => {
+ assert.deepEqual(urlFieldWarnings("indeed", { urls: "https://google.com", country: "us" }), {
+ urls: "Not an Indeed URL: https://google.com",
+ });
+ assert.deepEqual(urlFieldWarnings("indeed", { urls: "https://www.indeed.com/jobs?q=x" }), {});
+});
diff --git a/surfsense_web/lib/playground/url-hints.ts b/surfsense_web/lib/playground/url-hints.ts
new file mode 100644
index 000000000..e45a5d8ea
--- /dev/null
+++ b/surfsense_web/lib/playground/url-hints.ts
@@ -0,0 +1,90 @@
+/**
+ * Client-side, per-platform URL hints for the playground (UX only).
+ *
+ * The scraper API stays authoritative: it rejects malformed URLs (422), and
+ * each platform decides what it can actually scrape. These hints only *warn*,
+ * before a run, when a line in a platform's ``urls`` field is not a URL for
+ * that platform — so a typo (wrong site, missing scheme) is caught without a
+ * round-trip. They never block the run.
+ */
+
+interface PlatformUrlRule {
+ /** Host suffixes (``youtube.com``) or ``.``-terminated prefixes (``amazon.``). */
+ hosts: string[];
+ /** Human platform name used in the warning message. */
+ label: string;
+}
+
+/**
+ * Keyed by platform slug. Instagram is intentionally absent: its ``urls`` field
+ * also accepts bare ``@handles``, so a non-URL line there is not a mistake.
+ */
+const PLATFORM_URL_RULES: Record = {
+ amazon: { hosts: ["amazon.", "amzn.to", "a.co"], label: "Amazon" },
+ walmart: { hosts: ["walmart.com"], label: "Walmart" },
+ reddit: { hosts: ["reddit.com", "redd.it"], label: "Reddit" },
+ youtube: { hosts: ["youtube.com", "youtu.be"], label: "YouTube" },
+ tiktok: { hosts: ["tiktok.com"], label: "TikTok" },
+ google_maps: { hosts: ["google.", "goo.gl"], label: "Google Maps" },
+ indeed: { hosts: ["indeed.com"], label: "Indeed" },
+};
+
+/** The array fields that carry platform URLs at the capability layer. */
+const URL_FIELD_NAMES = new Set(["urls", "video_urls", "startUrls"]);
+
+function hostMatches(host: string, patterns: string[]): boolean {
+ return patterns.some((pattern) =>
+ pattern.endsWith(".")
+ ? host.includes(pattern)
+ : host === pattern || host.endsWith(`.${pattern}`)
+ );
+}
+
+function article(word: string): "a" | "an" {
+ return /^[aeiou]/i.test(word) ? "an" : "a";
+}
+
+function isPlatformUrl(line: string, rule: PlatformUrlRule): boolean {
+ try {
+ const { protocol, hostname } = new URL(line);
+ if (protocol !== "http:" && protocol !== "https:") return false;
+ return hostMatches(hostname.toLowerCase(), rule.hosts);
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Warn if any line in a platform ``urls`` field is not a URL for that platform.
+ * Returns ``undefined`` when the field/platform has no rule or every line is OK.
+ */
+export function urlFieldWarning(
+ platform: string,
+ fieldName: string,
+ value: unknown
+): string | undefined {
+ const rule = PLATFORM_URL_RULES[platform];
+ if (!rule || !URL_FIELD_NAMES.has(fieldName)) return undefined;
+
+ const badLines = String(value ?? "")
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .filter((line) => !isPlatformUrl(line, rule));
+
+ if (badLines.length === 0) return undefined;
+ return `Not ${article(rule.label)} ${rule.label} URL: ${badLines.join(", ")}`;
+}
+
+/** Per-field warnings for the current form values (empty when every URL looks valid). */
+export function urlFieldWarnings(
+ platform: string,
+ values: Record
+): Record {
+ const warnings: Record = {};
+ for (const [name, value] of Object.entries(values)) {
+ const warning = urlFieldWarning(platform, name, value);
+ if (warning) warnings[name] = warning;
+ }
+ return warnings;
+}
From 15962d205f8bbd09d92014850b99223fe113d27d Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 18:52:50 +0200
Subject: [PATCH 166/217] feat: surface validation feedback in playground UI
---
.../components/playground-runner.tsx | 20 ++++++++++++-
.../playground/components/schema-form.tsx | 8 +++++
.../components/webcrawler-config.tsx | 30 ++++++++++++++++---
surfsense_web/lib/url.ts | 10 +++++++
4 files changed, 63 insertions(+), 5 deletions(-)
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx
index 32624ca55..6fbf7b98b 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx
@@ -12,6 +12,7 @@ import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
import { AppError } from "@/lib/error";
import { findVerb } from "@/lib/playground/catalog";
+import { fieldErrorsFromError } from "@/lib/playground/field-errors";
import { formatCost, formatDuration, formatPricing } from "@/lib/playground/format";
import { buildPayload, initialFormValues, parseSchemaFields } from "@/lib/playground/json-schema";
import {
@@ -19,6 +20,7 @@ import {
getAmazonFieldOptions,
hasAmazonFranceValue,
} from "@/lib/playground/platform-overrides/amazon";
+import { urlFieldWarnings } from "@/lib/playground/url-hints";
import { ApiReference } from "./api-reference";
import { OutputViewer } from "./output-viewer";
import { RunProgressPanel } from "./run-progress-panel";
@@ -73,7 +75,12 @@ function getRunErrorMessage(error: unknown): string {
}
if (status === 422) {
- return "Invalid input. Check the fields above and try again.";
+ if (Object.keys(fieldErrorsFromError(error)).length > 0) {
+ return "Invalid input. Check the highlighted fields.";
+ }
+ return error instanceof Error && error.message
+ ? error.message
+ : "Invalid input. Check the fields above and try again.";
}
return error instanceof Error && error.message
@@ -123,6 +130,7 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
const fields = useMemo(() => parseSchemaFields(capability?.input_schema), [capability]);
const [values, setValues] = useState>({});
+ const [fieldErrors, setFieldErrors] = useState>({});
const run = useRunStream(workspaceId);
const isRunning = run.status === "running";
const previousStatusRef = useRef(run.status);
@@ -164,9 +172,15 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
const handleChange = (name: string, value: unknown) => {
setValues((prev) => ({ ...prev, [name]: value }));
+ setFieldErrors((prev) => {
+ if (!(name in prev)) return prev;
+ const { [name]: _cleared, ...rest } = prev;
+ return rest;
+ });
};
const handleRun = useCallback(() => {
+ setFieldErrors({});
const payload = buildPayload(fields, values);
void run.start(platform, verb, payload);
}, [fields, values, platform, verb, run]);
@@ -178,6 +192,7 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
const endpoint = `POST /workspaces/${workspaceId}/scrapers/${platform}/${verb}`;
const isAmazonScrape = platform === "amazon" && verb === "scrape";
const hasAmazonFranceUrl = useMemo(() => hasAmazonFranceValue(values), [values]);
+ const fieldWarnings = useMemo(() => urlFieldWarnings(platform, values), [platform, values]);
useEffect(() => {
const previousStatus = previousStatusRef.current;
@@ -194,6 +209,7 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
}
if (run.status === "error") {
+ setFieldErrors(fieldErrorsFromError(run.error));
const key = `${run.runId ?? "run"}:error`;
if (notifiedRunRef.current === key) return;
notifiedRunRef.current = key;
@@ -268,6 +284,8 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
onChange={handleChange}
disabled={isRunning}
getFieldOptions={isAmazonScrape ? getAmazonFieldOptions : undefined}
+ fieldErrors={fieldErrors}
+ fieldWarnings={fieldWarnings}
/>
{isAmazonScrape ? : null}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/schema-form.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/schema-form.tsx
index cdd425925..c86dd9839 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/schema-form.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/schema-form.tsx
@@ -32,6 +32,8 @@ interface SchemaFormProps {
getFieldOptions?: FieldOptionsResolver;
/** Field names flagged by a 422 response, shown with error styling. */
fieldErrors?: Record;
+ /** Client-side, non-blocking URL warnings keyed by field name. */
+ fieldWarnings?: Record;
}
function FieldControl({
@@ -147,6 +149,7 @@ function FieldRow({
onChange,
disabled,
error,
+ warning,
options,
}: {
field: FormField;
@@ -154,6 +157,7 @@ function FieldRow({
onChange: (value: unknown) => void;
disabled?: boolean;
error?: string;
+ warning?: string;
options?: FieldOption[];
}) {
return (
@@ -179,6 +183,7 @@ function FieldRow({
options={options}
/>
{error && {error}
}
+ {!error && warning && {warning}
}
);
}
@@ -190,6 +195,7 @@ export function SchemaForm({
disabled,
getFieldOptions,
fieldErrors,
+ fieldWarnings,
}: SchemaFormProps) {
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -209,6 +215,7 @@ export function SchemaForm({
onChange={(value) => onChange(field.name, value)}
disabled={disabled}
error={fieldErrors?.[field.name]}
+ warning={fieldWarnings?.[field.name]}
options={getFieldOptions?.(field)}
/>
))}
@@ -239,6 +246,7 @@ export function SchemaForm({
onChange={(value) => onChange(field.name, value)}
disabled={disabled}
error={fieldErrors?.[field.name]}
+ warning={fieldWarnings?.[field.name]}
options={getFieldOptions?.(field)}
/>
))}
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/webcrawler-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/webcrawler-config.tsx
index b7fbae91c..5a4540bcf 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/webcrawler-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/webcrawler-config.tsx
@@ -8,8 +8,18 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
+import { isHttpUrl } from "@/lib/url";
+import { cn } from "@/lib/utils";
import type { ConnectorConfigProps } from "../index";
+/** Return the malformed (non-http/https) lines from a newline-separated URL list. */
+function invalidUrlLines(value: string): string[] {
+ return value
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line && !isHttpUrl(line));
+}
+
export const WebcrawlerConfig: FC = ({ connector, onConfigChange }) => {
// Initialize with existing config values
const existingApiKey = (connector.config?.FIRECRAWL_API_KEY as string | undefined) || "";
@@ -19,6 +29,8 @@ export const WebcrawlerConfig: FC = ({ connector, onConfig
const [initialUrls, setInitialUrls] = useState(existingUrls);
const [showApiKey, setShowApiKey] = useState(false);
+ const invalidUrls = invalidUrlLines(initialUrls);
+
const handleApiKeyChange = (value: string) => {
setApiKey(value);
if (onConfigChange) {
@@ -108,11 +120,21 @@ export const WebcrawlerConfig: FC = ({ connector, onConfig
placeholder="https://example.com
https://docs.example.com
https://blog.example.com"
value={initialUrls}
onChange={(e) => handleUrlsChange(e.target.value)}
- className="min-h-[100px] font-mono text-xs sm:text-sm bg-slate-400/5 dark:bg-white/5 border-slate-400/20 resize-none"
+ aria-invalid={invalidUrls.length > 0}
+ className={cn(
+ "min-h-[100px] font-mono text-xs sm:text-sm bg-slate-400/5 dark:bg-white/5 border-slate-400/20 resize-none",
+ invalidUrls.length > 0 && "border-destructive"
+ )}
/>
-
- Enter URLs to crawl (one per line). You can add more URLs later.
-
+ {invalidUrls.length > 0 ? (
+
+ Not valid http(s) URLs: {invalidUrls.join(", ")}
+
+ ) : (
+
+ Enter URLs to crawl (one per line). You can add more URLs later.
+
+ )}
{/* Info Alert */}
diff --git a/surfsense_web/lib/url.ts b/surfsense_web/lib/url.ts
index 7bb0488d0..c351d1b18 100644
--- a/surfsense_web/lib/url.ts
+++ b/surfsense_web/lib/url.ts
@@ -12,3 +12,13 @@ export function tryGetHostname(url: string): string | undefined {
return undefined;
}
}
+
+/** True when the value parses as an http(s) URL — mirrors the backend's boundary rule. */
+export function isHttpUrl(value: string): boolean {
+ try {
+ const { protocol } = new URL(value);
+ return protocol === "http:" || protocol === "https:";
+ } catch {
+ return false;
+ }
+}
From 0118744c20380e0efc1cb82c83572b73df21fb55 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 19:39:15 +0200
Subject: [PATCH 167/217] fix: strip 'body' root from validation error summary
---
surfsense_backend/app/app.py | 7 ++-
.../tests/unit/test_error_contract.py | 44 ++++++++++++++++++-
2 files changed, 49 insertions(+), 2 deletions(-)
diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py
index de3b066f6..9f88fe02a 100644
--- a/surfsense_backend/app/app.py
+++ b/surfsense_backend/app/app.py
@@ -243,7 +243,12 @@ def _validation_error_handler(
]
def _segment(field: dict[str, Any]) -> str:
- loc = " -> ".join(field["loc"])
+ # Drop the "body" request root so model-level errors read as a plain
+ # sentence and field errors read as "field -> sub", not "body -> field".
+ path = field["loc"]
+ if path and path[0] == "body":
+ path = path[1:]
+ loc = " -> ".join(path)
return f"{loc}: {field['msg']}" if loc else field["msg"]
summary = "; ".join(_segment(f) for f in fields)
diff --git a/surfsense_backend/tests/unit/test_error_contract.py b/surfsense_backend/tests/unit/test_error_contract.py
index ec8021290..e1e160232 100644
--- a/surfsense_backend/tests/unit/test_error_contract.py
+++ b/surfsense_backend/tests/unit/test_error_contract.py
@@ -14,6 +14,7 @@ import json
import pytest
from fastapi import HTTPException
+from pydantic import BaseModel, model_validator
from starlette.testclient import TestClient
from app.exceptions import (
@@ -32,6 +33,23 @@ from app.exceptions import (
pytestmark = pytest.mark.unit
+class _RequireOne(BaseModel):
+ """Body model with a cross-field rule, used to test the 422 summary.
+
+ Defined at module scope (not inside the app factory) so FastAPI's ``Body``
+ ``TypeAdapter`` can resolve the forward reference.
+ """
+
+ a: str | None = None
+ b: str | None = None
+
+ @model_validator(mode="after")
+ def _at_least_one(self):
+ if not self.a and not self.b:
+ raise ValueError("Provide at least one of 'a' or 'b'.")
+ return self
+
+
# ---------------------------------------------------------------------------
# Helpers - lightweight FastAPI app that re-uses the real global handlers
# ---------------------------------------------------------------------------
@@ -39,7 +57,7 @@ pytestmark = pytest.mark.unit
def _make_test_app():
"""Build a minimal FastAPI app with the same handlers as the real one."""
- from fastapi import FastAPI
+ from fastapi import Body, FastAPI
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel
@@ -124,6 +142,10 @@ def _make_test_app():
async def validated(item: Item):
return item.model_dump()
+ @app.post("/require-one")
+ async def require_one(payload: _RequireOne = Body(...)):
+ return payload.model_dump()
+
return app
@@ -277,6 +299,26 @@ class TestValidationErrorHandler:
body = _assert_envelope(resp, 422)
assert body["error"]["code"] == "VALIDATION_ERROR"
+ def test_field_error_drops_body_root(self, client):
+ # The "body" request root is noise in the human summary; the field name
+ # is kept so clients can still attach the error inline.
+ resp = client.post("/require-one", json={"a": 123})
+ body = _assert_envelope(resp, 422)
+ msg = body["error"]["message"]
+ assert "body" not in msg
+ assert "a:" in msg
+ assert body["error"]["fields"][0]["loc"] == ["body", "a"]
+
+ def test_model_level_error_reads_as_sentence(self, client):
+ # A cross-field rule (loc == ["body"]) must read as a plain sentence,
+ # not "body: ...", while fields still carry the location for clients.
+ resp = client.post("/require-one", json={})
+ body = _assert_envelope(resp, 422)
+ assert body["error"]["message"] == (
+ "Validation failed: Provide at least one of 'a' or 'b'."
+ )
+ assert body["error"]["fields"][0]["loc"] == ["body"]
+
# ---------------------------------------------------------------------------
# SurfSenseError class hierarchy unit tests
From 55676cfe942f1b84b1735a6c8f268d0e1d3362df Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 19:39:15 +0200
Subject: [PATCH 168/217] fix: skip logging and telemetry for handled 4xx
errors
---
surfsense_web/lib/apis/base-api.service.ts | 46 ++++++++++++++--------
1 file changed, 29 insertions(+), 17 deletions(-)
diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts
index 872c885d9..867dea93e 100644
--- a/surfsense_web/lib/apis/base-api.service.ts
+++ b/surfsense_web/lib/apis/base-api.service.ts
@@ -293,24 +293,36 @@ class BaseApiService {
);
}
- console.error("Request failed:", JSON.stringify(error));
- if (!(error instanceof AuthenticationError)) {
- import("posthog-js")
- .then(({ default: posthog }) => {
- posthog.captureException(error, {
- api_url: url,
- api_method: options?.method ?? "GET",
- ...(error instanceof AppError && {
- status_code: error.status,
- status_text: error.statusText,
- error_code: error.code,
- request_id: error.requestId,
- }),
+ // Handled client errors (validation, credits, auth, not-found, ...) are
+ // expected outcomes surfaced to the user via typed errors + toasts — not
+ // app faults. Skip logging/telemetry for them so they don't spam the
+ // console, PostHog, or Next.js's dev error overlay.
+ const isHandledClientError =
+ error instanceof AppError &&
+ typeof error.status === "number" &&
+ error.status >= 400 &&
+ error.status < 500;
+
+ if (!isHandledClientError) {
+ console.error("Request failed:", JSON.stringify(error));
+ if (!(error instanceof AuthenticationError)) {
+ import("posthog-js")
+ .then(({ default: posthog }) => {
+ posthog.captureException(error, {
+ api_url: url,
+ api_method: options?.method ?? "GET",
+ ...(error instanceof AppError && {
+ status_code: error.status,
+ status_text: error.statusText,
+ error_code: error.code,
+ request_id: error.requestId,
+ }),
+ });
+ })
+ .catch(() => {
+ console.error("Failed to capture exception in PostHog");
});
- })
- .catch(() => {
- console.error("Failed to capture exception in PostHog");
- });
+ }
}
throw error;
}
From 44149323466b0ad46f0cae6da3e66dc4c755d2f4 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 19:39:15 +0200
Subject: [PATCH 169/217] feat: surface playground run errors inline and
persist toasts
---
.../components/playground-runner.tsx | 14 +++++++++++--
.../playground/components/schema-form.tsx | 21 ++++++++++++++++++-
2 files changed, 32 insertions(+), 3 deletions(-)
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx
index 6fbf7b98b..6bee3e4e5 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx
@@ -209,11 +209,21 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
}
if (run.status === "error") {
- setFieldErrors(fieldErrorsFromError(run.error));
+ const nextFieldErrors = fieldErrorsFromError(run.error);
+ setFieldErrors(nextFieldErrors);
const key = `${run.runId ?? "run"}:error`;
if (notifiedRunRef.current === key) return;
notifiedRunRef.current = key;
- toast.error(getRunErrorMessage(run.error));
+ // Field-level failures are shown inline (the form reveals + focuses the
+ // first one), so only toast global failures that have no field to anchor to.
+ // Keep run errors until dismissed — they're actionable and shouldn't
+ // vanish before the user reads them.
+ if (Object.keys(nextFieldErrors).length === 0) {
+ toast.error(getRunErrorMessage(run.error), {
+ duration: Number.POSITIVE_INFINITY,
+ closeButton: true,
+ });
+ }
}
}, [run.status, run.runId, run.error]);
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/schema-form.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/schema-form.tsx
index c86dd9839..b8eb72c66 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/schema-form.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/schema-form.tsx
@@ -1,7 +1,7 @@
"use client";
import { ChevronDown } from "lucide-react";
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -205,6 +205,25 @@ export function SchemaForm({
return { primary: primaryFields, advanced: advancedFields };
}, [fields]);
+ // First invalid field in display order; drives reveal + focus below.
+ const firstErrorName = useMemo(
+ () => (fieldErrors ? fields.find((f) => fieldErrors[f.name])?.name : undefined),
+ [fields, fieldErrors]
+ );
+
+ // Reveal the section holding an invalid field, then move focus to it, so an
+ // error is never left hidden inside the collapsed "Advanced" group.
+ useEffect(() => {
+ if (!firstErrorName) return;
+ if (advanced.some((f) => f.name === firstErrorName)) setShowAdvanced(true);
+ const raf = requestAnimationFrame(() => {
+ const el = document.getElementById(`field-${firstErrorName}`);
+ el?.focus();
+ el?.scrollIntoView({ block: "center", behavior: "smooth" });
+ });
+ return () => cancelAnimationFrame(raf);
+ }, [firstErrorName, advanced]);
+
return (
{primary.map((field) => (
From 9c16b7f40748b105d81591fe3b24d69e11f5bcf6 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 23 Jul 2026 23:37:16 +0530
Subject: [PATCH 170/217] feat(connectors): add dedicated connectors route with
sidebar panel
---
.../[workspace_id]/connectors/page.tsx | 5 +
.../connector-popup/connectors-panel.tsx | 548 ++++++++++++++++++
2 files changed, 553 insertions(+)
create mode 100644 surfsense_web/app/dashboard/[workspace_id]/connectors/page.tsx
create mode 100644 surfsense_web/components/assistant-ui/connector-popup/connectors-panel.tsx
diff --git a/surfsense_web/app/dashboard/[workspace_id]/connectors/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/connectors/page.tsx
new file mode 100644
index 000000000..d43a54d3d
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/connectors/page.tsx
@@ -0,0 +1,5 @@
+import { ConnectorsSection } from "@/components/assistant-ui/connector-popup/connectors-panel";
+
+export default function ConnectorsPage() {
+ return
;
+}
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connectors-panel.tsx b/surfsense_web/components/assistant-ui/connector-popup/connectors-panel.tsx
new file mode 100644
index 000000000..4e0e8b4b1
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/connector-popup/connectors-panel.tsx
@@ -0,0 +1,548 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { ChevronRight, LayoutGrid, Search, TriangleAlert, X } from "lucide-react";
+import { type ReactNode, useMemo, useState } from "react";
+import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
+import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
+import { Button } from "@/components/ui/button";
+import {
+ Drawer,
+ DrawerContent,
+ DrawerHandle,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer";
+import { Separator } from "@/components/ui/separator";
+import { Spinner } from "@/components/ui/spinner";
+import { EnumConnectorName } from "@/contracts/enums/connector";
+import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
+import type { SearchSourceConnector } from "@/contracts/types/connector.types";
+import { connectorIndexingMetadata } from "@/contracts/types/inbox.types";
+import { useConnectorsSync } from "@/hooks/use-connectors-sync";
+import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
+import { cn } from "@/lib/utils";
+import { ConnectorConnectView } from "./connector-configs/views/connector-connect-view";
+import { ConnectorEditView } from "./connector-configs/views/connector-edit-view";
+import { IndexingConfigurationView } from "./connector-configs/views/indexing-configuration-view";
+import { COMPOSIO_CONNECTORS, getConnectorTitle, OAUTH_CONNECTORS } from "./constants/connector-constants";
+import { useConnectorDialog } from "./hooks/use-connector-dialog";
+import { useIndexingConnectors } from "./hooks/use-indexing-connectors";
+import { AllConnectorsTab } from "./tabs/all-connectors-tab";
+import { ConnectorAccountsListView } from "./views/connector-accounts-list-view";
+import { YouTubeCrawlerView } from "./views/youtube-crawler-view";
+
+/** Health of a connected connector type, derived from indexing + inbox state. */
+type ConnectorHealth = "syncing" | "failed" | "ok";
+
+interface ConnectedRow {
+ type: string;
+ title: string;
+ connectors: SearchSourceConnector[];
+ accountCount: number;
+ health: ConnectorHealth;
+ errorMessage?: string;
+}
+
+/**
+ * Connector management surface (single `/connectors` route) rendered inside the
+ * workspace panel. Stateful master–detail:
+ * - sub-rail: Overview + a flat "Your connectors" list (only what's connected,
+ * each with a live status glyph) + an "Add MCP server" action;
+ * - detail pane: the flat catalog (search + cards) OR one of the existing flow
+ * views (connect / edit / indexing / accounts / YouTube), reused verbatim
+ * from the former dialog.
+ *
+ * Unconnected connectors are one click from the catalog cards, so they never
+ * clutter the rail. The hook's internal `isOpen`/`connectorDialogOpenAtom` is
+ * inert on a route and ignored.
+ */
+export function ConnectorsSection() {
+ const workspaceId = useAtomValue(activeWorkspaceIdAtom);
+ const documentTypeCounts = useZeroDocumentTypeCounts(workspaceId);
+ const statusInboxItems = useAtomValue(statusInboxItemsAtom);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+
+ const inboxItems = useMemo(
+ () => statusInboxItems.filter((item) => item.type === "connector_indexing"),
+ [statusInboxItems]
+ );
+
+ const {
+ connectingId,
+ searchQuery,
+ indexingConfig,
+ indexingConnector,
+ indexingConnectorConfig,
+ editingConnector,
+ connectingConnectorType,
+ isCreatingConnector,
+ startDate,
+ endDate,
+ isStartingIndexing,
+ isSaving,
+ isDisconnecting,
+ periodicEnabled,
+ frequencyMinutes,
+ enableVisionLlm,
+ allConnectors,
+ viewingAccountsType,
+ viewingMCPList,
+ isYouTubeView,
+ isFromOAuth,
+ setSearchQuery,
+ setStartDate,
+ setEndDate,
+ setPeriodicEnabled,
+ setFrequencyMinutes,
+ setEnableVisionLlm,
+ handleOpenChange,
+ handleConnectOAuth,
+ handleConnectNonOAuth,
+ handleCreateWebcrawler,
+ handleCreateYouTubeCrawler,
+ handleSubmitConnectForm,
+ handleStartIndexing,
+ handleSkipIndexing,
+ handleStartEdit,
+ handleSaveConnector,
+ handleDisconnectConnector,
+ handleBackFromEdit,
+ handleBackFromConnect,
+ handleBackFromYouTube,
+ handleViewAccountsList,
+ handleBackFromAccountsList,
+ handleViewMCPList,
+ handleBackFromMCPList,
+ handleAddNewMCPFromList,
+ handleQuickIndexConnector,
+ connectorConfig,
+ setConnectorConfig,
+ setIndexingConnectorConfig,
+ setConnectorName,
+ } = useConnectorDialog();
+
+ const {
+ connectors: connectorsFromSync = [],
+ loading: connectorsLoading,
+ error: connectorsError,
+ refreshConnectors: refreshConnectorsSync,
+ } = useConnectorsSync(workspaceId);
+
+ const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError);
+ const connectors = (useSyncData ? connectorsFromSync : allConnectors || []) as SearchSourceConnector[];
+
+ const refreshConnectors = async () => {
+ if (useSyncData) {
+ await refreshConnectorsSync();
+ }
+ };
+
+ const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
+ connectors,
+ inboxItems
+ );
+
+ const connectedTypes = useMemo(
+ () => new Set
(connectors.map((c) => c.connector_type)),
+ [connectors]
+ );
+
+ // Latest indexing status per connector id, parsed from the status inbox. Used
+ // to drive the "Your connectors" status glyphs (syncing / failed).
+ const statusByConnectorId = useMemo(() => {
+ const map = new Map();
+ for (const item of inboxItems) {
+ const parsed = connectorIndexingMetadata.safeParse(item.metadata);
+ if (!parsed.success) continue;
+ const at = item.updated_at ?? item.created_at;
+ const prev = map.get(parsed.data.connector_id);
+ if (!prev || at > prev.at) {
+ map.set(parsed.data.connector_id, {
+ status: parsed.data.status,
+ error: parsed.data.error_message ?? undefined,
+ at,
+ });
+ }
+ }
+ return map;
+ }, [inboxItems]);
+
+ // Flat "Your connectors" rows: one per connected type, alphabetized, with a
+ // derived health (syncing > failed > ok) and account count.
+ const connectedRows = useMemo(() => {
+ const byType = new Map();
+ for (const c of connectors) {
+ const arr = byType.get(c.connector_type) ?? [];
+ arr.push(c);
+ byType.set(c.connector_type, arr);
+ }
+ return [...byType.entries()]
+ .map(([type, list]) => {
+ const ids = list.map((c) => c.id);
+ const syncing = ids.some(
+ (id) => indexingConnectorIds.has(id) || statusByConnectorId.get(id)?.status === "in_progress"
+ );
+ const failedEntry = syncing
+ ? undefined
+ : ids.map((id) => statusByConnectorId.get(id)).find((s) => s?.status === "failed");
+ return {
+ type,
+ title:
+ type === EnumConnectorName.MCP_CONNECTOR ? "MCP Servers" : getConnectorTitle(type),
+ connectors: list,
+ accountCount: list.length,
+ health: syncing ? "syncing" : failedEntry ? "failed" : "ok",
+ errorMessage: failedEntry?.error,
+ } satisfies ConnectedRow;
+ })
+ .sort((a, b) => a.title.localeCompare(b.title));
+ }, [connectors, indexingConnectorIds, statusByConnectorId]);
+
+ if (!workspaceId) return null;
+
+ const activeConnectorType = editingConnector
+ ? editingConnector.connector_type
+ : connectingConnectorType ||
+ viewingAccountsType?.connectorType ||
+ (viewingMCPList ? EnumConnectorName.MCP_CONNECTOR : null);
+
+ const flowView = ((): ReactNode => {
+ if (isYouTubeView) {
+ return ;
+ }
+ if (viewingMCPList) {
+ return (
+
+ );
+ }
+ if (viewingAccountsType) {
+ return (
+ {
+ const oauthConnector =
+ OAUTH_CONNECTORS.find(
+ (c) => c.connectorType === viewingAccountsType.connectorType
+ ) ||
+ COMPOSIO_CONNECTORS.find(
+ (c) => c.connectorType === viewingAccountsType.connectorType
+ );
+ if (oauthConnector) {
+ handleConnectOAuth(oauthConnector);
+ }
+ }}
+ isConnecting={connectingId !== null}
+ />
+ );
+ }
+ if (connectingConnectorType) {
+ return (
+ handleSubmitConnectForm(formData, startIndexing)}
+ onBack={handleBackFromConnect}
+ isSubmitting={isCreatingConnector}
+ />
+ );
+ }
+ if (editingConnector) {
+ return (
+ c.id === editingConnector.id)?.last_indexed_at ??
+ editingConnector.last_indexed_at,
+ }}
+ startDate={startDate}
+ endDate={endDate}
+ periodicEnabled={periodicEnabled}
+ frequencyMinutes={frequencyMinutes}
+ enableVisionLlm={enableVisionLlm}
+ isSaving={isSaving}
+ isDisconnecting={isDisconnecting}
+ isIndexing={indexingConnectorIds.has(editingConnector.id)}
+ workspaceId={workspaceId?.toString()}
+ onStartDateChange={setStartDate}
+ onEndDateChange={setEndDate}
+ onPeriodicEnabledChange={setPeriodicEnabled}
+ onFrequencyChange={setFrequencyMinutes}
+ onEnableVisionLlmChange={setEnableVisionLlm}
+ onSave={() => {
+ startIndexing(editingConnector.id);
+ handleSaveConnector(() => refreshConnectors());
+ }}
+ onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
+ onBack={handleBackFromEdit}
+ onQuickIndex={(() => {
+ const cfg = connectorConfig || editingConnector.config;
+ const isDriveOrOneDrive =
+ editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
+ editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
+ editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
+ editingConnector.connector_type === "DROPBOX_CONNECTOR";
+ const hasDriveItems = isDriveOrOneDrive
+ ? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
+ ((cfg?.selected_files as unknown[]) ?? []).length > 0
+ : true;
+ if (!hasDriveItems) return undefined;
+ return () => {
+ startIndexing(editingConnector.id);
+ handleQuickIndexConnector(
+ editingConnector.id,
+ editingConnector.connector_type,
+ stopIndexing,
+ startDate,
+ endDate
+ );
+ };
+ })()}
+ onConfigChange={setConnectorConfig}
+ onNameChange={setConnectorName}
+ />
+ );
+ }
+ if (indexingConfig) {
+ return (
+ {
+ if (indexingConfig.connectorId) {
+ startIndexing(indexingConfig.connectorId);
+ }
+ handleStartIndexing(() => refreshConnectors());
+ }}
+ onSkip={handleSkipIndexing}
+ />
+ );
+ }
+ return null;
+ })();
+
+ const isFlowActive = flowView !== null;
+
+ const detailTitle =
+ isFlowActive && activeConnectorType ? getConnectorTitle(activeConnectorType) : "Overview";
+
+ const hasConnected = connectedRows.length > 0;
+
+ // Rail navigation: clear any open flow first (handleOpenChange(false) is the
+ // hook's full reset), then apply the target management action.
+ const resetFlow = () => handleOpenChange(false);
+ const goOverview = () => {
+ setDrawerOpen(false);
+ resetFlow();
+ };
+ const manageRow = (row: ConnectedRow) => {
+ setDrawerOpen(false);
+ resetFlow();
+ if (row.type === EnumConnectorName.MCP_CONNECTOR) {
+ handleViewMCPList();
+ return;
+ }
+ if (row.accountCount > 1) {
+ handleViewAccountsList(row.type, row.title);
+ return;
+ }
+ handleStartEdit(row.connectors[0]);
+ };
+ const addMcpServer = () => {
+ setDrawerOpen(false);
+ resetFlow();
+ handleConnectNonOAuth?.(EnumConnectorName.MCP_CONNECTOR);
+ };
+
+ const navBtnClass = (active: boolean) =>
+ cn(
+ "inline-flex w-full items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none",
+ active
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
+ );
+
+ const railLabel = (text: string) => (
+ {text}
+ );
+
+ const renderNav = () => (
+ <>
+
+
+ Overview
+
+
+ {hasConnected && (
+ <>
+
+ {railLabel("Your connectors")}
+
+ {connectedRows.map((row) => {
+ const isActive = isFlowActive && activeConnectorType === row.type;
+ return (
+ manageRow(row)}
+ className={cn(
+ "inline-flex w-full items-center justify-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors duration-150 focus:outline-none",
+ isActive
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
+ )}
+ >
+
+ {getConnectorIcon(row.type, "size-4")}
+
+ {row.title}
+ {row.health === "syncing" ? (
+
+ ) : row.health === "failed" ? (
+
+ ) : row.accountCount > 1 ? (
+
+ {row.accountCount}
+
+ ) : null}
+
+ );
+ })}
+
+ >
+ )}
+
+
+
+ {getConnectorIcon(EnumConnectorName.MCP_CONNECTOR, "h-4 w-4")}
+ Add MCP server
+
+ >
+ );
+
+ const catalog = (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ />
+ {searchQuery && (
+ setSearchQuery("")}
+ className="absolute right-1.5 top-1/2 size-7 -translate-y-1/2 text-muted-foreground transition-colors hover:bg-transparent hover:text-accent-foreground"
+ aria-label="Clear search"
+ >
+
+
+ )}
+
+
+
+
+
+ );
+
+ return (
+
+
+
+ Connectors
+
+ {renderNav()}
+
+
+
+ {detailTitle}
+
+
+
+
+
+ Connectors navigation
+
+ {renderNav()}
+
+
+
+
+
+
+
+
{detailTitle}
+
+
+
{isFlowActive ? flowView : catalog}
+
+
+ );
+}
From e705a73ae952f25744e24b805f482ebbfb0d57c5 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 23 Jul 2026 23:37:26 +0530
Subject: [PATCH 171/217] feat(sidebar): add Connectors nav item to workspace
sidebar
---
.../layout/providers/LayoutDataProvider.tsx | 14 ++++++++++++--
.../components/layout/ui/sidebar/Sidebar.tsx | 15 +++++++++++++++
2 files changed, 27 insertions(+), 2 deletions(-)
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index 511c095e5..cfd970f13 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -2,7 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
-import { AlarmClock, AlertTriangle, Shapes, SquareTerminal } from "lucide-react";
+import { AlarmClock, AlertTriangle, Shapes, SquareTerminal, Unplug } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@@ -300,6 +300,7 @@ export function LayoutDataProvider({
const isAutomationsActive = pathname?.includes("/automations") === true;
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
const isPlaygroundRoute = pathname?.includes("/playground") === true;
+ const isConnectorsRoute = pathname?.includes("/connectors") === true;
const navItems: NavItem[] = useMemo(
() =>
(
@@ -316,6 +317,12 @@ export function LayoutDataProvider({
icon: Shapes,
isActive: isArtifactsActive,
},
+ {
+ title: "Connectors",
+ url: `/dashboard/${workspaceId}/connectors`,
+ icon: Unplug,
+ isActive: isConnectorsRoute,
+ },
{
title: "Playground",
url: `/dashboard/${workspaceId}/playground`,
@@ -324,7 +331,7 @@ export function LayoutDataProvider({
},
] as (NavItem | null)[]
).filter((item): item is NavItem => item !== null),
- [workspaceId, isAutomationsActive, isArtifactsActive, isPlaygroundRoute]
+ [workspaceId, isAutomationsActive, isArtifactsActive, isConnectorsRoute, isPlaygroundRoute]
);
// Handlers
@@ -631,6 +638,7 @@ export function LayoutDataProvider({
const isAutomationsPage = pathname?.includes("/automations") === true;
const isArtifactsPage = pathname?.endsWith("/artifacts") === true;
const isPlaygroundPage = pathname?.includes("/playground") === true;
+ const isConnectorsPage = pathname?.includes("/connectors") === true;
const isAllChatsPage = pathname?.endsWith("/chats") === true;
const handleChatsClick = useCallback(() => {
router.push(`/dashboard/${workspaceId}/chats`);
@@ -650,6 +658,7 @@ export function LayoutDataProvider({
isAutomationsPage ||
isArtifactsPage ||
isPlaygroundPage ||
+ isConnectorsPage ||
isAllChatsPage;
return (
@@ -702,6 +711,7 @@ export function LayoutDataProvider({
isAutomationsPage ||
isArtifactsPage ||
isPlaygroundPage ||
+ isConnectorsPage ||
isAllChatsPage
? "items-start justify-center px-6 py-8 md:px-10 md:pb-10 md:pt-16"
: undefined
diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
index e24523448..0d280dc28 100644
--- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
@@ -140,6 +140,10 @@ export function Sidebar({
() => navItems.find((item) => item.url.endsWith("/artifacts")),
[navItems]
);
+ const connectorsItem = useMemo(
+ () => navItems.find((item) => item.url.endsWith("/connectors")),
+ [navItems]
+ );
const playgroundItem = useMemo(
() => navItems.find((item) => item.url.endsWith("/playground")),
[navItems]
@@ -150,6 +154,7 @@ export function Sidebar({
(item) =>
!item.url.endsWith("/automations") &&
!item.url.endsWith("/artifacts") &&
+ !item.url.endsWith("/connectors") &&
!item.url.endsWith("/playground")
),
[navItems]
@@ -254,6 +259,16 @@ export function Sidebar({
tooltipContent={isCollapsed ? artifactsItem.title : undefined}
/>
)}
+ {connectorsItem && (
+ onNavItemClick?.(connectorsItem)}
+ isCollapsed={isCollapsed}
+ isActive={connectorsItem.isActive}
+ tooltipContent={isCollapsed ? connectorsItem.title : undefined}
+ />
+ )}
{playgroundItem && (
Date: Thu, 23 Jul 2026 23:37:30 +0530
Subject: [PATCH 172/217] fix(connectors): land OAuth callback on connectors
panel to resume flow
---
.../app/dashboard/[workspace_id]/connectors/callback/route.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/surfsense_web/app/dashboard/[workspace_id]/connectors/callback/route.ts b/surfsense_web/app/dashboard/[workspace_id]/connectors/callback/route.ts
index 09a2c2f2c..857075e58 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/connectors/callback/route.ts
+++ b/surfsense_web/app/dashboard/[workspace_id]/connectors/callback/route.ts
@@ -19,7 +19,9 @@ export async function GET(
const response = new NextResponse(null, {
status: 302,
headers: {
- Location: `/dashboard/${workspace_id}/new-chat`,
+ // Land on the connectors panel so `useConnectorDialog` (mounted there)
+ // consumes the result cookie and continues the indexing/edit flow.
+ Location: `/dashboard/${workspace_id}/connectors`,
},
});
response.cookies.set(OAUTH_RESULT_COOKIE, result, {
From 132fd1e5d63a379062fee838f87cec46e20d6572 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 23 Jul 2026 23:37:40 +0530
Subject: [PATCH 173/217] refactor(connectors): remove legacy
ConnectorIndicator modal
---
.../[workspace_id]/client-layout.tsx | 2 -
.../assistant-ui/connector-popup.tsx | 388 ------------------
.../assistant-ui/connector-popup/index.ts | 3 -
3 files changed, 393 deletions(-)
delete mode 100644 surfsense_web/components/assistant-ui/connector-popup.tsx
diff --git a/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx b/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx
index 54fc9d000..fb76af227 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx
@@ -8,7 +8,6 @@ import { useEffect } from "react";
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms";
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
-import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
import { LayoutDataProvider } from "@/components/layout";
import { OnboardingTour } from "@/components/onboarding-tour";
@@ -169,7 +168,6 @@ export function DashboardClientLayout({
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
>
{children}
-
);
diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx
deleted file mode 100644
index a8bfc29bd..000000000
--- a/surfsense_web/components/assistant-ui/connector-popup.tsx
+++ /dev/null
@@ -1,388 +0,0 @@
-"use client";
-
-import { useAtomValue } from "jotai";
-import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
-import { createPortal } from "react-dom";
-import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
-import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
-import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
-import { Tabs, TabsContent } from "@/components/ui/tabs";
-import type { SearchSourceConnector } from "@/contracts/types/connector.types";
-import { useConnectorsSync } from "@/hooks/use-connectors-sync";
-import { PICKER_CLOSE_EVENT, PICKER_OPEN_EVENT } from "@/hooks/use-google-picker";
-import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
-import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
-import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
-import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
-import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
-import {
- COMPOSIO_CONNECTORS,
- OAUTH_CONNECTORS,
-} from "./connector-popup/constants/connector-constants";
-import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog";
-import { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-connectors";
-import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
-import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
-import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
-import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
-
-export interface ConnectorIndicatorHandle {
- open: () => void;
-}
-
-interface ConnectorIndicatorProps {
- showTrigger?: boolean;
-}
-
-export const ConnectorIndicator = forwardRef(
- (_props, ref) => {
- const workspaceId = useAtomValue(activeWorkspaceIdAtom);
-
- // Real-time document type counts via Zero (updates instantly as docs are indexed)
- const documentTypeCounts = useZeroDocumentTypeCounts(workspaceId);
- // Read status inbox items from shared atom (populated by LayoutDataProvider)
- // instead of creating a duplicate useInbox("status") hook.
- const statusInboxItems = useAtomValue(statusInboxItemsAtom);
- const inboxItems = useMemo(
- () => statusInboxItems.filter((item) => item.type === "connector_indexing"),
- [statusInboxItems]
- );
-
- // Use the custom hook for dialog state management
- const {
- isOpen,
- activeTab,
- connectingId,
- isScrolled,
- searchQuery,
- indexingConfig,
- indexingConnector,
- indexingConnectorConfig,
- editingConnector,
- connectingConnectorType,
- isCreatingConnector,
- startDate,
- endDate,
- isStartingIndexing,
- isSaving,
- isDisconnecting,
- periodicEnabled,
- frequencyMinutes,
- enableVisionLlm,
- allConnectors,
- viewingAccountsType,
- viewingMCPList,
- isYouTubeView,
- isFromOAuth,
- setSearchQuery,
- setStartDate,
- setEndDate,
- setPeriodicEnabled,
- setFrequencyMinutes,
- setEnableVisionLlm,
- handleOpenChange,
- handleTabChange,
- handleScroll,
- handleConnectOAuth,
- handleConnectNonOAuth,
- handleCreateWebcrawler,
- handleCreateYouTubeCrawler,
- handleSubmitConnectForm,
- handleStartIndexing,
- handleSkipIndexing,
- handleStartEdit,
- handleSaveConnector,
- handleDisconnectConnector,
- handleBackFromEdit,
- handleBackFromConnect,
- handleBackFromYouTube,
- handleViewAccountsList,
- handleBackFromAccountsList,
- handleBackFromMCPList,
- handleAddNewMCPFromList,
- handleQuickIndexConnector,
- connectorConfig,
- setConnectorConfig,
- setIndexingConnectorConfig,
- setConnectorName,
- } = useConnectorDialog();
-
- const [pickerOpen, setPickerOpen] = useState(false);
- useEffect(() => {
- const onOpen = () => setPickerOpen(true);
- const onClose = () => setPickerOpen(false);
- window.addEventListener(PICKER_OPEN_EVENT, onOpen);
- window.addEventListener(PICKER_CLOSE_EVENT, onClose);
- return () => {
- window.removeEventListener(PICKER_OPEN_EVENT, onOpen);
- window.removeEventListener(PICKER_CLOSE_EVENT, onClose);
- };
- }, []);
-
- const {
- connectors: connectorsFromSync = [],
- loading: connectorsLoading,
- error: connectorsError,
- refreshConnectors: refreshConnectorsSync,
- } = useConnectorsSync(workspaceId);
-
- const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError);
- const connectors = useSyncData ? connectorsFromSync : allConnectors || [];
-
- const refreshConnectors = async () => {
- if (useSyncData) {
- await refreshConnectorsSync();
- }
- };
-
- // Track indexing state locally - clears automatically when last_indexed_at changes via real-time sync
- // Also clears when failed notifications are detected
- const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
- connectors as SearchSourceConnector[],
- inboxItems
- );
-
- // Get document types that have documents in the workspace
- const activeDocumentTypes = documentTypeCounts
- ? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
- : [];
-
- const hasConnectors = connectors.length > 0;
- const hasSources = hasConnectors || activeDocumentTypes.length > 0;
- const totalSourceCount = connectors.length + activeDocumentTypes.length;
-
- const activeConnectorsCount = connectors.length;
-
- // Check which connectors are already connected
- // Real-time connector updates via Zero sync
- const connectedTypes = new Set(
- (connectors || []).map((c: SearchSourceConnector) => c.connector_type)
- );
-
- useImperativeHandle(ref, () => ({
- open: () => handleOpenChange(true),
- }));
-
- if (!workspaceId) return null;
-
- return (
-
- {isOpen &&
- createPortal(
- {
- if (!pickerOpen) handleOpenChange(false);
- }}
- />,
- document.body
- )}
-
-
e.preventDefault()}
- onInteractOutside={(e) => {
- if (pickerOpen) e.preventDefault();
- }}
- onPointerDownOutside={(e) => {
- if (pickerOpen) e.preventDefault();
- }}
- className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden ring-0 dark:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-accent [&>button]:hover:text-accent-foreground [&>button>svg]:size-5 select-none"
- >
- MCP Connectors
- {/* YouTube Crawler View - shown when adding YouTube videos */}
- {isYouTubeView && workspaceId ? (
-
- ) : viewingMCPList ? (
-
- ) : viewingAccountsType ? (
- {
- // Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
- const oauthConnector =
- OAUTH_CONNECTORS.find(
- (c) => c.connectorType === viewingAccountsType.connectorType
- ) ||
- COMPOSIO_CONNECTORS.find(
- (c) => c.connectorType === viewingAccountsType.connectorType
- );
- if (oauthConnector) {
- handleConnectOAuth(oauthConnector);
- }
- }}
- isConnecting={connectingId !== null}
- />
- ) : connectingConnectorType ? (
- handleSubmitConnectForm(formData, startIndexing)}
- onBack={handleBackFromConnect}
- isSubmitting={isCreatingConnector}
- />
- ) : editingConnector ? (
- c.id === editingConnector.id)
- ?.last_indexed_at ?? editingConnector.last_indexed_at,
- }}
- startDate={startDate}
- endDate={endDate}
- periodicEnabled={periodicEnabled}
- frequencyMinutes={frequencyMinutes}
- enableVisionLlm={enableVisionLlm}
- isSaving={isSaving}
- isDisconnecting={isDisconnecting}
- isIndexing={indexingConnectorIds.has(editingConnector.id)}
- workspaceId={workspaceId?.toString()}
- onStartDateChange={setStartDate}
- onEndDateChange={setEndDate}
- onPeriodicEnabledChange={setPeriodicEnabled}
- onFrequencyChange={setFrequencyMinutes}
- onEnableVisionLlmChange={setEnableVisionLlm}
- onSave={() => {
- startIndexing(editingConnector.id);
- handleSaveConnector(() => refreshConnectors());
- }}
- onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
- onBack={handleBackFromEdit}
- onQuickIndex={(() => {
- const cfg = connectorConfig || editingConnector.config;
- const isDriveOrOneDrive =
- editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
- editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
- editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
- editingConnector.connector_type === "DROPBOX_CONNECTOR";
- const hasDriveItems = isDriveOrOneDrive
- ? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
- ((cfg?.selected_files as unknown[]) ?? []).length > 0
- : true;
- if (!hasDriveItems) return undefined;
- return () => {
- startIndexing(editingConnector.id);
- handleQuickIndexConnector(
- editingConnector.id,
- editingConnector.connector_type,
- stopIndexing,
- startDate,
- endDate
- );
- };
- })()}
- onConfigChange={setConnectorConfig}
- onNameChange={setConnectorName}
- />
- ) : indexingConfig ? (
- {
- if (indexingConfig.connectorId) {
- startIndexing(indexingConfig.connectorId);
- }
- handleStartIndexing(() => refreshConnectors());
- }}
- onSkip={handleSkipIndexing}
- />
- ) : (
-
- {/* Header */}
-
-
- {/* Content */}
-
-
- {/* Bottom fade shadow */}
-
-
-
- )}
-
-
- );
- }
-);
-
-ConnectorIndicator.displayName = "ConnectorIndicator";
diff --git a/surfsense_web/components/assistant-ui/connector-popup/index.ts b/surfsense_web/components/assistant-ui/connector-popup/index.ts
index f3209a777..8fd895e71 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/index.ts
+++ b/surfsense_web/components/assistant-ui/connector-popup/index.ts
@@ -1,6 +1,3 @@
-// Main component export
-export { ConnectorIndicator } from "../connector-popup";
-
// Sub-components (if needed for external use)
export { ConnectorCard } from "./components/connector-card";
export { ConnectorDialogHeader } from "./components/connector-dialog-header";
From 915ebcf2e74d2e3e9d521ed4357af54200e35309 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 23 Jul 2026 23:37:48 +0530
Subject: [PATCH 174/217] refactor(chat): open connectors page from
composer/banner and restyle tool-group headings
---
.../components/assistant-ui/thread.tsx | 31 ++++++++++++-------
1 file changed, 19 insertions(+), 12 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx
index 73d31e11d..120af4bfe 100644
--- a/surfsense_web/components/assistant-ui/thread.tsx
+++ b/surfsense_web/components/assistant-ui/thread.tsx
@@ -44,7 +44,6 @@ import {
clearPremiumAlertForThreadAtom,
premiumAlertByThreadAtom,
} from "@/atoms/chat/premium-alert.atom";
-import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms";
@@ -328,7 +327,9 @@ const ConnectToolsBanner: FC<{
onVisibleChange?: (visible: boolean) => void;
}> = ({ isThreadEmpty, onVisibleChange }) => {
const { data: connectors } = useAtomValue(connectorsAtom);
- const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
+ const router = useRouter();
+ const params = useParams();
+ const bannerWorkspaceId = getWorkspaceIdNumber(params);
const [dismissed, setDismissed] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem(BANNER_DISMISSED_KEY) === "true";
@@ -371,7 +372,9 @@ const ConnectToolsBanner: FC<{
variant="ghost"
size="sm"
className="h-7 min-w-0 cursor-pointer justify-start gap-2 rounded-md px-0 text-[13px] font-normal text-muted-foreground select-none hover:bg-transparent hover:text-foreground"
- onClick={() => setConnectorDialogOpen(true)}
+ onClick={() => {
+ if (bannerWorkspaceId) router.push(`/dashboard/${bannerWorkspaceId}/connectors`);
+ }}
>
Connect your tools
@@ -1152,7 +1155,11 @@ const ComposerAction: FC
= ({
onChatModelSelected,
}) => {
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
- const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
+ const router = useRouter();
+ const openConnectors = useCallback(
+ () => router.push(`/dashboard/${workspaceId}/connectors`),
+ [router, workspaceId]
+ );
const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false);
const [openConnectorSubmenu, setOpenConnectorSubmenu] = useState(null);
const [expandedConnectorGroups, setExpandedConnectorGroups] = useState>(
@@ -1302,7 +1309,7 @@ const ComposerAction: FC = ({
Upload Files
- setConnectorDialogOpen(true)}>
+
MCP Connectors
@@ -1327,7 +1334,7 @@ const ComposerAction: FC = ({
{regularToolGroups.map((group) => (
-
+
{group.label}
{group.tools.map((tool) => {
@@ -1354,7 +1361,7 @@ const ComposerAction: FC
= ({
))}
{connectorToolGroups.length > 0 && (
-
+
Connector Actions
{connectorToolGroups.map((group) => {
@@ -1435,7 +1442,7 @@ const ComposerAction: FC
= ({
)}
{otherToolGroup && (
-
+
{otherToolGroup.label}
{otherToolGroup.tools.map((tool) => {
@@ -1522,7 +1529,7 @@ const ComposerAction: FC
= ({
Take a screenshot
- setConnectorDialogOpen(true)}>
+
MCP Connectors
@@ -1546,7 +1553,7 @@ const ComposerAction: FC = ({
>
{regularToolGroups.map((group) => (
-
+
{group.label}
{group.tools.map((tool) => {
@@ -1581,7 +1588,7 @@ const ComposerAction: FC
= ({
))}
{connectorToolGroups.length > 0 && (
-
+
Connector Actions
{connectorToolGroups.map((group) => {
@@ -1667,7 +1674,7 @@ const ComposerAction: FC
= ({
)}
{otherToolGroup && (
-
+
{otherToolGroup.label}
{otherToolGroup.tools.map((tool) => {
From 6c1c75a3e1fc5a869a56c595db4e2b5d68f25394 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 23 Jul 2026 23:37:51 +0530
Subject: [PATCH 175/217] feat(connector-icon): mask MCP icon to inherit
currentColor
---
surfsense_web/contracts/enums/connectorIcons.tsx | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/surfsense_web/contracts/enums/connectorIcons.tsx b/surfsense_web/contracts/enums/connectorIcons.tsx
index 81696729d..1e76d7e45 100644
--- a/surfsense_web/contracts/enums/connectorIcons.tsx
+++ b/surfsense_web/contracts/enums/connectorIcons.tsx
@@ -74,7 +74,17 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
case EnumConnectorName.CIRCLEBACK_CONNECTOR:
return
;
case EnumConnectorName.MCP_CONNECTOR:
- return
;
+ // Masked so the black glyph inherits currentColor (white in dark mode).
+ return (
+
+ );
case EnumConnectorName.OBSIDIAN_CONNECTOR:
return
;
case EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR:
From fb5671fa9a57c104d8ed07226a40e7a17e65104c Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 23 Jul 2026 23:37:55 +0530
Subject: [PATCH 176/217] refactor(all-connectors): flatten catalog and drop
category/deprecated sections
---
.../tabs/all-connectors-tab.tsx | 140 ++++--------------
1 file changed, 30 insertions(+), 110 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
index ea98f258f..8c82ecfb9 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
@@ -9,12 +9,8 @@ import { usePlatform } from "@/hooks/use-platform";
import { ConnectorCard } from "../components/connector-card";
import {
COMPOSIO_CONNECTORS,
- CONNECTOR_CATEGORY_LABELS,
- type ConnectorCategory,
CRAWLERS,
DEPRECATED_CONNECTOR_TYPES,
- getConnectorCategory,
- IMPORT_CONNECTOR_TYPES,
OAUTH_CONNECTORS,
OTHER_CONNECTORS,
} from "../constants/connector-constants";
@@ -83,63 +79,39 @@ export const AllConnectorsTab: FC
= ({
const passesDeploymentFilter = (c: DeploymentFilterableConnector) =>
(!c.selfHostedOnly || selfHosted) && (!c.desktopOnly || isDesktop);
- // Import connectors (Drive/OneDrive/Dropbox) are managed via the Documents
- // sidebar "Import" menu, so they never appear in the MCP connector catalog.
- const notImport = (c: { connectorType?: string }) =>
- !c.connectorType || !IMPORT_CONNECTOR_TYPES.has(c.connectorType);
-
- // Filter connectors based on search, deployment mode, and import exclusion
+ // Filter connectors based on search and deployment mode
const filteredOAuth = OAUTH_CONNECTORS.filter(
- (c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c) && notImport(c)
+ (c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
);
const filteredCrawlers = CRAWLERS.filter(
- (c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c) && notImport(c)
+ (c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
);
const filteredOther = OTHER_CONNECTORS.filter(
- (c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c) && notImport(c)
+ (c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
);
// Filter Composio connectors
const filteredComposio = COMPOSIO_CONNECTORS.filter(
(c) =>
- (c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
- c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
- notImport(c)
+ c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ c.description.toLowerCase().includes(searchQuery.toLowerCase())
);
- const isDeprecated = (c: { connectorType?: string }) =>
- !!c.connectorType && DEPRECATED_CONNECTOR_TYPES.has(c.connectorType);
-
- const inCategory =
- (category: ConnectorCategory) =>
- (connector: T): boolean =>
- !isDeprecated(connector) &&
- !!connector.connectorType &&
- getConnectorCategory(connector.connectorType) === category;
-
- const knowledgeBase = {
- oauth: filteredOAuth.filter(inCategory("knowledge_base")),
- composio: filteredComposio.filter(inCategory("knowledge_base")),
- other: filteredOther.filter(inCategory("knowledge_base")),
- crawlers: filteredCrawlers.filter(inCategory("knowledge_base")),
- };
- const toolsLive = {
- oauth: filteredOAuth.filter(inCategory("tools_live")),
- composio: filteredComposio.filter(inCategory("tools_live")),
- other: filteredOther.filter(inCategory("tools_live")),
- crawlers: filteredCrawlers.filter(inCategory("tools_live")),
- };
-
- // Deprecated cards render in a single section at the very bottom. Discord and
- // Teams come from OAUTH_CONNECTORS; Luma, Tavily, Linkup, Baidu, Elasticsearch
- // from OTHER_CONNECTORS (SearXNG has no catalog card); YouTube and Web Pages
- // from CRAWLERS.
- const deprecated = {
- oauth: filteredOAuth.filter(isDeprecated),
- other: filteredOther.filter(isDeprecated),
- crawlers: filteredCrawlers.filter(isDeprecated),
+ // One flat grid, no separate "Deprecated" section. A deprecated connector is
+ // shown only if the user already connected it (before deprecation), so it
+ // stays manageable/disconnectable; never-connected deprecated connectors are
+ // hidden entirely. See DEPRECATED_CONNECTOR_TYPES for the full list.
+ const isVisible = (c: T) =>
+ !c.connectorType ||
+ !DEPRECATED_CONNECTOR_TYPES.has(c.connectorType) ||
+ connectedTypes.has(c.connectorType);
+ const available = {
+ oauth: filteredOAuth.filter(isVisible),
+ composio: filteredComposio.filter(isVisible),
+ other: filteredOther.filter(isVisible),
+ crawlers: filteredCrawlers.filter(isVisible),
};
const renderOAuthCard = (connector: OAuthConnector | ComposioConnector) => {
@@ -169,7 +141,6 @@ export const AllConnectorsTab: FC = ({
documentCount={documentCount}
accountCount={accountCount}
isIndexing={isIndexing}
- deprecated={DEPRECATED_CONNECTOR_TYPES.has(connector.connectorType)}
onConnect={() => onConnectOAuth(connector)}
onManage={
isConnected && onViewAccountsList
@@ -218,7 +189,6 @@ export const AllConnectorsTab: FC = ({
documentCount={documentCount}
connectorCount={mcpConnectorCount}
isIndexing={isIndexing}
- deprecated={DEPRECATED_CONNECTOR_TYPES.has(connector.connectorType)}
onConnect={handleConnect}
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
/>
@@ -267,29 +237,17 @@ export const AllConnectorsTab: FC = ({
isConnecting={isConnecting}
documentCount={documentCount}
isIndexing={isIndexing}
- deprecated={
- !!crawler.connectorType && DEPRECATED_CONNECTOR_TYPES.has(crawler.connectorType)
- }
onConnect={handleConnect}
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
/>
);
};
- const hasKnowledgeBase =
- knowledgeBase.oauth.length > 0 ||
- knowledgeBase.composio.length > 0 ||
- knowledgeBase.other.length > 0 ||
- knowledgeBase.crawlers.length > 0;
- const hasToolsLive =
- toolsLive.oauth.length > 0 ||
- toolsLive.composio.length > 0 ||
- toolsLive.other.length > 0 ||
- toolsLive.crawlers.length > 0;
- const hasDeprecated =
- deprecated.oauth.length > 0 || deprecated.other.length > 0 || deprecated.crawlers.length > 0;
-
- const hasAnyResults = hasKnowledgeBase || hasToolsLive || hasDeprecated;
+ const hasAnyResults =
+ available.oauth.length > 0 ||
+ available.composio.length > 0 ||
+ available.other.length > 0 ||
+ available.crawlers.length > 0;
if (!hasAnyResults && searchQuery) {
return (
@@ -303,50 +261,12 @@ export const AllConnectorsTab: FC = ({
return (
- {hasKnowledgeBase && (
-
-
-
- {CONNECTOR_CATEGORY_LABELS.knowledge_base}
-
-
-
- {knowledgeBase.oauth.map(renderOAuthCard)}
- {knowledgeBase.composio.map(renderOAuthCard)}
- {knowledgeBase.crawlers.map(renderCrawlerCard)}
- {knowledgeBase.other.map(renderOtherCard)}
-
-
- )}
-
- {hasToolsLive && (
-
-
-
- {CONNECTOR_CATEGORY_LABELS.tools_live}
-
-
-
- {toolsLive.oauth.map(renderOAuthCard)}
- {toolsLive.composio.map(renderOAuthCard)}
- {toolsLive.crawlers.map(renderCrawlerCard)}
- {toolsLive.other.map(renderOtherCard)}
-
-
- )}
-
- {hasDeprecated && (
-
-
-
Deprecated
-
-
- {deprecated.oauth.map(renderOAuthCard)}
- {deprecated.crawlers.map(renderCrawlerCard)}
- {deprecated.other.map(renderOtherCard)}
-
-
- )}
+
+ {available.oauth.map(renderOAuthCard)}
+ {available.composio.map(renderOAuthCard)}
+ {available.crawlers.map(renderCrawlerCard)}
+ {available.other.map(renderOtherCard)}
+
);
};
From 50c3e1adf7359883e9416f3d8a8bf043257e17df Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 23 Jul 2026 23:37:58 +0530
Subject: [PATCH 177/217] refactor(connector-card): drop deprecated state and
inline MCP icon
---
.../components/connector-card.tsx | 42 +++++--------------
1 file changed, 11 insertions(+), 31 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx
index d8e4b174c..dadfe32ab 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx
@@ -23,7 +23,6 @@ interface ConnectorCardProps {
accountCount?: number;
connectorCount?: number;
isIndexing?: boolean;
- deprecated?: boolean;
onConnect?: () => void;
onManage?: () => void;
}
@@ -53,15 +52,11 @@ export const ConnectorCard: FC = ({
accountCount,
connectorCount,
isIndexing = false,
- deprecated = false,
onConnect,
onManage,
}) => {
const isMCP = connectorType === EnumConnectorName.MCP_CONNECTOR;
const isLive = !!connectorType && LIVE_CONNECTOR_TYPES.has(connectorType);
- // Deprecated connectors can no longer be connected, but existing rows stay
- // manageable (so users can disconnect them).
- const isDeprecatedForConnect = deprecated && !isConnected;
// Get connector status
const { getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings } =
useConnectorStatus();
@@ -78,10 +73,6 @@ export const ConnectorCard: FC = ({
return null;
}
- if (isDeprecatedForConnect) {
- return "Deprecated. No longer available to connect.";
- }
-
return description;
};
@@ -102,16 +93,7 @@ export const ConnectorCard: FC = ({
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
)}
>
- {isMCP ? (
-
- ) : connectorType ? (
+ {connectorType ? (
getConnectorIcon(connectorType, "size-6")
) : id === "youtube-crawler" ? (
@@ -169,20 +151,18 @@ export const ConnectorCard: FC = ({
!isConnected && "shadow-xs"
)}
onClick={isConnected ? onManage : onConnect}
- disabled={isConnecting || !isEnabled || isDeprecatedForConnect}
+ disabled={isConnecting || !isEnabled}
>
- {isDeprecatedForConnect
- ? "Deprecated"
- : !isEnabled
- ? "Unavailable"
- : isConnected
- ? "Manage"
- : id === "youtube-crawler"
- ? "Add"
- : connectorType
- ? "Connect"
- : "Add"}
+ {!isEnabled
+ ? "Unavailable"
+ : isConnected
+ ? "Manage"
+ : id === "youtube-crawler"
+ ? "Add"
+ : connectorType
+ ? "Connect"
+ : "Add"}
{isConnecting && }
From bc31d594d79e1c18fdc92002338a582ffc48bce1 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 23 Jul 2026 23:38:02 +0530
Subject: [PATCH 178/217] refactor(active-connectors): show import connectors
in active list
---
.../connector-popup/tabs/active-connectors-tab.tsx | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
index 24ee6473f..3d895f2c3 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
@@ -11,7 +11,6 @@ import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
import { cn } from "@/lib/utils";
import {
COMPOSIO_CONNECTORS,
- IMPORT_CONNECTOR_TYPES,
LIVE_CONNECTOR_TYPES,
OAUTH_CONNECTORS,
} from "../constants/connector-constants";
@@ -40,11 +39,7 @@ export const ActiveConnectorsTab: FC = ({
onManage,
onViewAccountsList,
}) => {
- // Import connectors (Drive/OneDrive/Dropbox) are managed via the Documents
- // sidebar "Import" menu, so they are excluded from the MCP connector list.
- const connectors = allActiveConnectors.filter(
- (c) => !IMPORT_CONNECTOR_TYPES.has(c.connector_type)
- );
+ const connectors = allActiveConnectors;
// Convert activeDocumentTypes array to Record for utility function
const documentTypeCounts = activeDocumentTypes.reduce(
(acc, [docType, count]) => {
From 3acffb6818e26047a675ac6bec7c15712abf2c17 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 23 Jul 2026 23:38:14 +0530
Subject: [PATCH 179/217] refactor(connector-constants): remove import-type and
category helpers
---
.../constants/connector-constants.ts | 69 +++++--------------
1 file changed, 16 insertions(+), 53 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts
index 0aaef7ba3..a91fa4d9f 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts
+++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts
@@ -2,41 +2,36 @@ import { EnumConnectorName } from "@/contracts/enums/connector";
/**
* Connectors retired during the MCP migration (no viable official MCP server).
- * The catalog card is shown disabled with a "Deprecated" badge so existing
- * users understand why; the backend `/add` routes also refuse with HTTP 410.
- * Reinstate by removing the type here and in the backend
- * `DEPRECATED_CONNECTOR_TYPES` if demand returns.
+ *
+ * UI behavior: a deprecated connector is hidden from the catalog entirely unless
+ * the user already connected it before deprecation — in that case it still shows
+ * as a normal card (inline, no "Deprecated" section) so they can manage/disconnect
+ * it. New connections are refused by the backend `/add` routes with HTTP 410.
+ * Reinstate by removing the type here and in the backend `DEPRECATED_CONNECTOR_TYPES`
+ * if demand returns.
+ *
+ * Full deprecated list (for devs):
+ * - DISCORD_CONNECTOR, TEAMS_CONNECTOR, LUMA_CONNECTOR
+ * - Search APIs (superseded by the Google-only web-search consolidation; the
+ * google_search subagent handles public web search, and Tavily/Linkup can
+ * still be added via the generic Custom MCP connector with API-key headers):
+ * TAVILY_API, SEARXNG_API, LINKUP_API, BAIDU_SEARCH_API
+ * - Legacy content crawlers/search (superseded by the file Import menu and
+ * hosted MCP tooling): YOUTUBE_CONNECTOR, WEBCRAWLER_CONNECTOR, ELASTICSEARCH_CONNECTOR
*/
export const DEPRECATED_CONNECTOR_TYPES = new Set([
EnumConnectorName.DISCORD_CONNECTOR,
EnumConnectorName.TEAMS_CONNECTOR,
EnumConnectorName.LUMA_CONNECTOR,
- // Search APIs retired by the Google-only web-search consolidation. Public
- // web search now runs through the google_search subagent; Tavily/Linkup can
- // still be added via the generic Custom MCP connector (API-key headers).
EnumConnectorName.TAVILY_API,
EnumConnectorName.SEARXNG_API,
EnumConnectorName.LINKUP_API,
EnumConnectorName.BAIDU_SEARCH_API,
- // Legacy content crawlers/search retired in favor of the file Import menu and
- // hosted MCP tooling. Existing rows stay manageable; new connections refused.
EnumConnectorName.YOUTUBE_CONNECTOR,
EnumConnectorName.WEBCRAWLER_CONNECTOR,
EnumConnectorName.ELASTICSEARCH_CONNECTOR,
]);
-/**
- * File-import connectors surfaced through the Documents sidebar "Import" menu
- * instead of the external-MCP connector catalog. They index remote files into
- * the knowledge base, so they belong with uploads, not with live MCP tools.
- */
-export const IMPORT_CONNECTOR_TYPES = new Set([
- EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
- EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
- EnumConnectorName.ONEDRIVE_CONNECTOR,
- EnumConnectorName.DROPBOX_CONNECTOR,
-]);
-
/**
* Connectors that operate in real time (no background indexing).
* Used to adjust UI: hide sync controls, show "Connected" instead of doc counts.
@@ -283,38 +278,6 @@ export function getConnectorTitle(connectorType: string): string {
);
}
-/**
- * Primary way a user interacts with a connector.
- * Drives the two top-level groupings in the connector catalog UI.
- */
-export type ConnectorCategory = "knowledge_base" | "tools_live";
-
-export const CONNECTOR_CATEGORY_LABELS: Record = {
- knowledge_base: "Knowledge Base",
- tools_live: "Tools & Live Sources",
-};
-
-const KNOWLEDGE_BASE_CONNECTOR_TYPES = new Set([
- EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
- EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
- EnumConnectorName.ONEDRIVE_CONNECTOR,
- EnumConnectorName.DROPBOX_CONNECTOR,
- EnumConnectorName.NOTION_CONNECTOR,
- EnumConnectorName.CONFLUENCE_CONNECTOR,
- EnumConnectorName.YOUTUBE_CONNECTOR,
- EnumConnectorName.WEBCRAWLER_CONNECTOR,
- EnumConnectorName.BOOKSTACK_CONNECTOR,
- EnumConnectorName.GITHUB_CONNECTOR,
- EnumConnectorName.ELASTICSEARCH_CONNECTOR,
- EnumConnectorName.CIRCLEBACK_CONNECTOR,
- EnumConnectorName.OBSIDIAN_CONNECTOR,
-]);
-
-/** Unmapped connectors surface under Tools & Live Sources. */
-export function getConnectorCategory(connectorType: string): ConnectorCategory {
- return KNOWLEDGE_BASE_CONNECTOR_TYPES.has(connectorType) ? "knowledge_base" : "tools_live";
-}
-
// Composio Toolkits (available integrations via Composio)
export const COMPOSIO_TOOLKITS = [
{
From 98bdfbdbdb52b63758fc5d39819ee4df088c8d54 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 23 Jul 2026 23:38:18 +0530
Subject: [PATCH 180/217] feat(connector-edit): fall back to connected card for
config-less live connectors
---
.../components/composio-calendar-config.tsx | 14 ------
.../components/composio-gmail-config.tsx | 14 ------
.../live-connector-connected-card.tsx | 48 +++++++++++++++++++
.../connector-configs/index.tsx | 8 +---
.../views/connector-edit-view.tsx | 16 +++++--
5 files changed, 61 insertions(+), 39 deletions(-)
delete mode 100644 surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-calendar-config.tsx
delete mode 100644 surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-gmail-config.tsx
create mode 100644 surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/live-connector-connected-card.tsx
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-calendar-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-calendar-config.tsx
deleted file mode 100644
index 6f282d892..000000000
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-calendar-config.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-"use client";
-
-import type { FC } from "react";
-import type { SearchSourceConnector } from "@/contracts/types/connector.types";
-
-interface ComposioCalendarConfigProps {
- connector: SearchSourceConnector;
- onConfigChange?: (config: Record) => void;
- onNameChange?: (name: string) => void;
-}
-
-export const ComposioCalendarConfig: FC = () => {
- return
;
-};
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-gmail-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-gmail-config.tsx
deleted file mode 100644
index 494e1362f..000000000
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-gmail-config.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-"use client";
-
-import type { FC } from "react";
-import type { SearchSourceConnector } from "@/contracts/types/connector.types";
-
-interface ComposioGmailConfigProps {
- connector: SearchSourceConnector;
- onConfigChange?: (config: Record) => void;
- onNameChange?: (name: string) => void;
-}
-
-export const ComposioGmailConfig: FC = () => {
- return
;
-};
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/live-connector-connected-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/live-connector-connected-card.tsx
new file mode 100644
index 000000000..dddd4210a
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/live-connector-connected-card.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { CheckCircle2 } from "lucide-react";
+import type { FC } from "react";
+import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
+import {
+ COMPOSIO_CONNECTORS,
+ CRAWLERS,
+ OAUTH_CONNECTORS,
+ OTHER_CONNECTORS,
+} from "../../constants/connector-constants";
+import type { ConnectorConfigProps } from "../index";
+
+// Catalog descriptions keyed by connector type, reused as the capability line so
+// the copy stays in sync with the catalog cards.
+const DESCRIPTION_BY_TYPE = new Map(
+ [...OAUTH_CONNECTORS, ...COMPOSIO_CONNECTORS, ...OTHER_CONNECTORS, ...CRAWLERS].map((c) => [
+ c.connectorType,
+ c.description,
+ ])
+);
+
+/**
+ * Fallback manage view for live connectors that are neither MCP-backed nor have
+ * a dedicated config component (native/Composio Gmail & Calendar). There is
+ * nothing to configure, so we just confirm the connection and echo what the
+ * agent can do — no Trusted Tools (that feature is MCP-only, see the backend
+ * `_ensure_mcp_connector_for_user`).
+ */
+export const LiveConnectorConnectedCard: FC = ({ connector }) => {
+ const capability =
+ DESCRIPTION_BY_TYPE.get(connector.connector_type) ??
+ `connect to ${getConnectorTypeDisplay(connector.connector_type)}`;
+
+ return (
+
+
+
+
+
+
+
Connected
+
{capability}.
+
+
+
+ );
+};
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx
index a994a3db2..8f332df65 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx
@@ -55,12 +55,8 @@ const configMap: Record Promise<{ default: FC ({ default: m.ObsidianConfig })),
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: () =>
import("./components/composio-drive-config").then((m) => ({ default: m.ComposioDriveConfig })),
- COMPOSIO_GMAIL_CONNECTOR: () =>
- import("./components/composio-gmail-config").then((m) => ({ default: m.ComposioGmailConfig })),
- COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: () =>
- import("./components/composio-calendar-config").then((m) => ({
- default: m.ComposioCalendarConfig,
- })),
+ // Composio Gmail/Calendar have nothing to configure; the edit view falls back
+ // to LiveConnectorConnectedCard for live connectors without a config here.
};
const componentCache = new Map();
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx
index 4874ded34..38de72107 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx
@@ -20,6 +20,7 @@ import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import { VisionLLMConfig } from "../../components/vision-llm-config";
import { LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
+import { LiveConnectorConnectedCard } from "../components/live-connector-connected-card";
import { MCPServiceConfig } from "../components/mcp-service-config";
import { getConnectorConfigComponent } from "../index";
@@ -124,8 +125,13 @@ export const ConnectorEditView: FC = ({
// Get connector-specific config component (MCP-backed connectors use a generic view)
const ConnectorConfigComponent = useMemo(() => {
if (isMCPBacked) return MCPServiceConfig;
- return getConnectorConfigComponent(connector.connector_type);
- }, [connector.connector_type, isMCPBacked]);
+ // Live connectors without a dedicated config (native/Composio Gmail &
+ // Calendar) fall back to a simple "Connected" card instead of a blank body.
+ return (
+ getConnectorConfigComponent(connector.connector_type) ??
+ (isLive ? LiveConnectorConnectedCard : null)
+ );
+ }, [connector.connector_type, isMCPBacked, isLive]);
const [isScrolled, setIsScrolled] = useState(false);
const [hasMoreContent, setHasMoreContent] = useState(false);
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
@@ -201,7 +207,7 @@ export const ConnectorEditView: FC = ({
{/* Fixed Header */}
@@ -262,7 +268,7 @@ export const ConnectorEditView: FC
= ({
@@ -362,7 +368,7 @@ export const ConnectorEditView: FC = ({
{/* Fixed Footer - Action buttons */}
-
+
{showDisconnectConfirm ? (
From 15b22a72a4c6e22547655f8d02efac0c96e41a92 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 23 Jul 2026 23:38:22 +0530
Subject: [PATCH 181/217] style(connector-views): remove internal padding for
panel embedding
---
.../connector-configs/views/connector-connect-view.tsx | 10 +++++-----
.../views/indexing-configuration-view.tsx | 6 +++---
.../views/connector-accounts-list-view.tsx | 4 ++--
.../connector-popup/views/youtube-crawler-view.tsx | 6 +++---
4 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx
index 7eaea271e..b00483afe 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx
@@ -89,7 +89,7 @@ export const ConnectorConnectView: FC = ({
return (
{/* Header */}
-
+
= ({
{/* Form Content - Scrollable */}
= ({
{/* Fixed Footer - Action buttons */}
-
-
+ {/*
Cancel
-
+ */}
= ({
{/* Fixed Header */}
@@ -166,7 +166,7 @@ export const IndexingConfigurationView: FC
= ({
@@ -240,7 +240,7 @@ export const IndexingConfigurationView: FC = ({
{/* Fixed Footer - Action buttons */}
-
+
{isLive ? (
Done
diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx
index 6a2f7dec7..0a7051db5 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx
@@ -111,7 +111,7 @@ export const ConnectorAccountsListView: FC = ({
return (
{/* Header */}
-
+
{/* Back button */}
= ({
{/* Content */}
-
+
{/* Connected Accounts Grid */}
{typeConnectors.length === 0 ? (
diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/youtube-crawler-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/youtube-crawler-view.tsx
index 4223418b7..7aed8061d 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/views/youtube-crawler-view.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/views/youtube-crawler-view.tsx
@@ -216,7 +216,7 @@ export const YouTubeCrawlerView: FC
= ({ workspaceId, o
return (
{/* Header */}
-
+
= ({ workspaceId, o
{/* Form Content - Scrollable */}
-
+
@@ -325,7 +325,7 @@ export const YouTubeCrawlerView: FC = ({ workspaceId, o
{/* Fixed Footer - Action buttons */}
-
+
Date: Thu, 23 Jul 2026 23:38:27 +0530
Subject: [PATCH 182/217] refactor(documents-sidebar): reduce import menu to
watch-local-folder
---
.../layout/ui/sidebar/DocumentsSidebar.tsx | 134 ++----------------
1 file changed, 13 insertions(+), 121 deletions(-)
diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
index a24b1d619..f8b71fd2e 100644
--- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx
@@ -8,25 +8,18 @@ import {
FolderPlus,
FolderSync,
ListFilter,
- Plus,
- Settings2,
SlidersVertical,
Trash2,
- Upload,
} from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { makeFolderMention, mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
-import { importConnectorRequestAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
-import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
-import { useConnectorStatus } from "@/components/assistant-ui/connector-popup/hooks/use-connector-status";
-import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
import { getDocumentTypeIcon } from "@/components/documents/DocumentTypeIcon";
@@ -34,7 +27,7 @@ import type { FolderDisplay } from "@/components/documents/FolderNode";
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
import { FolderTreeView } from "@/components/documents/FolderTreeView";
import { VersionHistoryDialog } from "@/components/documents/version-history";
-import { useOptionalRuntimeConfig, useRuntimeConfig } from "@/components/providers/runtime-config";
+import { useRuntimeConfig } from "@/components/providers/runtime-config";
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import {
DEFAULT_EXCLUDE_PATTERNS,
@@ -57,7 +50,6 @@ import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
@@ -67,9 +59,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate";
-import { EnumConnectorName } from "@/contracts/enums/connector";
-import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
-import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useIsMobile } from "@/hooks/use-mobile";
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
@@ -239,11 +228,9 @@ export function EmbeddedDocumentsMenu({
}
/**
- * Import menu: local file upload plus the cloud-drive import connectors
- * (Google Drive / OneDrive / Dropbox). Drive/OneDrive/Dropbox set
- * `importConnectorRequestAtom`, which the connector dialog consumes to run
- * OAuth or open the existing account's config. In anonymous mode, `gate`
- * intercepts every item to trigger the login flow.
+ * Desktop-only "Watch Local Folder" entry point. File uploads now live in the
+ * chat composer and cloud drives in the connector catalog, so this renders
+ * nothing on web. `gate` login-gates the action in anonymous mode.
*/
export function EmbeddedImportMenu({
gate,
@@ -252,30 +239,14 @@ export function EmbeddedImportMenu({
gate?: (feature: string) => void;
onFolderWatched?: () => void;
}) {
- const { openDialog } = useDocumentUploadDialog();
- const setImportRequest = useSetAtom(importConnectorRequestAtom);
- // Provider is absent on anonymous /free pages, where every item is login-gated
- // anyway — defaulting to hosted (Composio) there is cosmetic.
- const selfHosted = useOptionalRuntimeConfig()?.deploymentMode === "self-hosted";
- const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus();
- const { data: connectors } = useAtomValue(connectorsAtom);
-
// Watch Local Folder is a desktop-app feature (needs the Electron folder watcher).
const { isDesktop } = usePlatform();
const params = useParams();
const workspaceId = getWorkspaceIdNumber(params) ?? 0;
const [folderWatchOpen, setFolderWatchOpen] = useState(false);
- // Native Google Drive connector self-hosted only; hosted deployments use Composio.
- const driveType = selfHosted
- ? EnumConnectorName.GOOGLE_DRIVE_CONNECTOR
- : EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR;
-
- const cloudItems = [
- { type: driveType, label: "Google Drive" },
- { type: EnumConnectorName.ONEDRIVE_CONNECTOR, label: "OneDrive" },
- { type: EnumConnectorName.DROPBOX_CONNECTOR, label: "Dropbox" },
- ];
+ // Nothing to import on web anymore — hide the button rather than show an empty menu.
+ if (!isDesktop) return null;
return (
@@ -285,99 +256,20 @@ export function EmbeddedImportMenu({
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
- aria-label="Import documents"
+ aria-label="Watch local folder"
>
- (gate ? gate("upload files") : openDialog())}>
-
- Upload Files
+ (gate ? gate("watch local folders") : setFolderWatchOpen(true))}
+ >
+
+ Watch Local Folder
- {isDesktop && (
- (gate ? gate("watch local folders") : setFolderWatchOpen(true))}
- >
-
- Watch Local Folder
-
- )}
-
- {cloudItems.map((item) => {
- const enabled = gate ? true : isConnectorEnabled(item.type);
- const statusMessage = enabled ? null : getConnectorStatusMessage(item.type);
- const icon = getConnectorIcon(item.type, "h-4 w-4");
- // gate = anonymous mode; treat every connector as unconnected so items
- // route through the login gate rather than reading workspace connectors.
- const accountCount = gate
- ? 0
- : (connectors ?? []).filter(
- (c: SearchSourceConnector) => c.connector_type === item.type
- ).length;
-
- // Unavailable (e.g. maintenance): non-actionable item explaining why.
- if (!enabled) {
- return (
-
- {icon}
- {item.label}
-
- );
- }
-
- // Connected: manage the existing account(s) or add another.
- if (accountCount > 0) {
- return (
-
-
- {icon}
- {item.label}
- {accountCount}
-
-
-
- gate
- ? gate("manage import connectors")
- : setImportRequest({ connectorType: item.type, mode: "auto" })
- }
- >
-
- {accountCount > 1 ? "Manage accounts" : "Manage"}
-
-
- gate
- ? gate("import from cloud storage")
- : setImportRequest({ connectorType: item.type, mode: "connect" })
- }
- >
-
- Add another account
-
-
-
- );
- }
-
- // Not connected: single click starts the first OAuth connect.
- return (
-
- gate
- ? gate("import from cloud storage")
- : setImportRequest({ connectorType: item.type, mode: "auto" })
- }
- >
- {icon}
- {item.label}
-
- );
- })}
- {isDesktop && !gate && (
+ {!gate && (
Date: Thu, 23 Jul 2026 23:38:33 +0530
Subject: [PATCH 183/217] chore(mcp-form): reword MCP server description
---
.../connect-forms/components/mcp-connect-form.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
index 92df6f5a1..0d2eb8b36 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
@@ -139,7 +139,7 @@ export const MCPConnectForm: FC = ({ onSubmit, isSubmitting })
- Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate
+ Connect to a Model Context Protocol server. Each MCP server is added as a separate
connector.
From 094ef7af6fa28fb9343e9f1e8f343bc3c9223396 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 23 Jul 2026 23:38:39 +0530
Subject: [PATCH 184/217] docs(connectors): rename tools & live sources to
integrations
---
surfsense_web/content/docs/connectors/index.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/surfsense_web/content/docs/connectors/index.mdx b/surfsense_web/content/docs/connectors/index.mdx
index 5cb507b97..103500e46 100644
--- a/surfsense_web/content/docs/connectors/index.mdx
+++ b/surfsense_web/content/docs/connectors/index.mdx
@@ -26,7 +26,7 @@ Connectors bring data into SurfSense — either as searchable knowledge or as li
## How connectors surface in SurfSense
- **Native scrapers** — the AI agent uses them as tools automatically in chat, and you can run them yourself from the **API Playground** or your own code via the REST API.
-- **Tools & Live Sources** — external connectors like Notion, Slack, Jira, or Linear give the agent real-time tools. Ask a question in chat and the agent searches or acts on the connected service directly; nothing is copied into SurfSense in the background.
+- **Integrations** — external connectors like Notion, Slack, Jira, or Linear give the agent real-time tools. Ask a question in chat and the agent searches or acts on the connected service directly; nothing is copied into SurfSense in the background.
- **Knowledge Base** — file sources like Google Drive, OneDrive, and Dropbox are imported through the Documents sidebar's **Import** menu and indexed into your knowledge base alongside uploads and notes.
To manage external connectors, open the **Connectors** dialog inside your workspace. Connected accounts, sync status, and disconnect options all live there.
From 9945801a28482deebc019f9437beb70b79a1e63d Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 23 Jul 2026 23:40:02 +0530
Subject: [PATCH 185/217] fix(mcp-connect-form): update key prop in tool list
to use tool name for improved uniqueness
---
.../connect-forms/components/mcp-connect-form.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
index 0d2eb8b36..9ab45e621 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
@@ -275,8 +275,8 @@ export const MCPConnectForm: FC = ({ onSubmit, isSubmitting })
Available tools:
- {testResult.tools.map((tool, i) => (
- {tool.name}
+ {testResult.tools.map((tool) => (
+ {tool.name}
))}
From bea46980eaf13d0fd5e2e34c59be907a9605f781 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 23 Jul 2026 23:44:21 +0530
Subject: [PATCH 186/217] refactor(thread-ui): simplify Composer component by
removing unused props and ConnectToolsBanner
---
.../components/assistant-ui/thread.tsx | 116 +-----------------
1 file changed, 3 insertions(+), 113 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx
index 120af4bfe..5c31af139 100644
--- a/surfsense_web/components/assistant-ui/thread.tsx
+++ b/surfsense_web/components/assistant-ui/thread.tsx
@@ -22,7 +22,6 @@ import {
Wrench,
X,
} from "lucide-react";
-import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useParams, useRouter } from "next/navigation";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -89,7 +88,6 @@ import { Popover, PopoverAnchor } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import {
CONNECTOR_ICON_TO_TYPES,
CONNECTOR_TOOL_ICON_PATHS,
@@ -172,7 +170,7 @@ const ThreadContent: FC = ({
footer={
<>
-
+
>
}
>
@@ -312,103 +310,6 @@ const ThreadWelcome: FC = () => {
);
};
-const BANNER_CONNECTORS = [
- { type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
- { type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" },
- { type: "NOTION_CONNECTOR", label: "Notion" },
- { type: "YOUTUBE_CONNECTOR", label: "YouTube" },
- { type: "SLACK_CONNECTOR", label: "Slack" },
-] as const;
-
-const BANNER_DISMISSED_KEY = "surfsense-connect-tools-banner-dismissed";
-
-const ConnectToolsBanner: FC<{
- isThreadEmpty: boolean;
- onVisibleChange?: (visible: boolean) => void;
-}> = ({ isThreadEmpty, onVisibleChange }) => {
- const { data: connectors } = useAtomValue(connectorsAtom);
- const router = useRouter();
- const params = useParams();
- const bannerWorkspaceId = getWorkspaceIdNumber(params);
- const [dismissed, setDismissed] = useState(() => {
- if (typeof window === "undefined") return false;
- return localStorage.getItem(BANNER_DISMISSED_KEY) === "true";
- });
- const [dismissRequested, setDismissRequested] = useState(false);
-
- const hasConnectors = (connectors?.length ?? 0) > 0;
- const isVisible = !dismissed && !hasConnectors && isThreadEmpty;
- const shouldShowTray = isVisible && !dismissRequested;
-
- useEffect(() => {
- onVisibleChange?.(isVisible);
- }, [isVisible, onVisibleChange]);
-
- const handleDismiss = (e: React.MouseEvent) => {
- e.stopPropagation();
- setDismissRequested(true);
- };
-
- return (
- {
- if (!dismissRequested) return;
- setDismissed(true);
- localStorage.setItem(BANNER_DISMISSED_KEY, "true");
- }}
- >
- {shouldShowTray ? (
-
- {
- if (bannerWorkspaceId) router.push(`/dashboard/${bannerWorkspaceId}/connectors`);
- }}
- >
-
- Connect your tools
-
-
-
- {BANNER_CONNECTORS.map(({ type }, i) => (
-
-
- {getConnectorIcon(type, "size-3")}
-
-
- ))}
-
-
-
-
-
- ) : null}
-
- );
-};
-
const PendingScreenImageStrip: FC = () => {
const [urls, setUrls] = useAtom(pendingUserImageDataUrlsAtom);
if (urls.length === 0) return null;
@@ -488,8 +389,7 @@ const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDi
};
/**
- * Inline "this workspace can't chat yet" tray, mirrored on top of the composer
- * (same visual treatment as the "Connect your tools" tray below it).
+ * Inline "this workspace can't chat yet" tray, mirrored on top of the composer.
*
* Replaces the old full-screen onboarding redirect: a workspace with no usable
* chat model (fresh install, or an admin deleted the last model) renders the
@@ -534,14 +434,10 @@ const ChatUnavailableNotice: FC<{ workspaceId: number; canConfigure: boolean }>
};
interface ComposerProps {
- hasActiveThread?: boolean;
isLoadingMessages?: boolean;
}
-const Composer: FC = ({
- hasActiveThread = false,
- isLoadingMessages = false,
-}) => {
+const Composer: FC = ({ isLoadingMessages = false }) => {
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const setSubmittedMentions = useSetAtom(submittedMentionsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
@@ -578,7 +474,6 @@ const Composer: FC = ({
const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
- const [connectToolsTrayVisible, setConnectToolsTrayVisible] = useState(false);
const { data: chatSetupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId ?? 0));
const isChatUnavailable = !!chatSetupStatus && chatSetupStatus.status !== "ready";
@@ -1040,7 +935,6 @@ const Composer: FC = ({
@@ -1074,10 +968,6 @@ const Composer: FC = ({
onChatModelSelected={handleChatModelSelected}
/>
-
{!isLoadingMessages && isThreadEmpty && isComposerInputEmpty ? (
From 50f3704662992f517e2ac7a76e47253b8db1ef58 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 23 Jul 2026 23:48:36 +0530
Subject: [PATCH 187/217] refactor(dashboard-error): update button components
for navigation and issue reporting
---
surfsense_web/app/dashboard/error.tsx | 24 +++++++++---------------
1 file changed, 9 insertions(+), 15 deletions(-)
diff --git a/surfsense_web/app/dashboard/error.tsx b/surfsense_web/app/dashboard/error.tsx
index 28a22fe3c..948ff134b 100644
--- a/surfsense_web/app/dashboard/error.tsx
+++ b/surfsense_web/app/dashboard/error.tsx
@@ -43,21 +43,15 @@ export default function DashboardError({
Try again
-
- Go to dashboard home
-
-
-
- Report Issue
-
+
+ Back to dashboard
+
+
+
+ Report Issue
+
+
+
);
From 1e801b3ae93ff35e58a6f313c162e0f026dcade2 Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 20:47:06 +0200
Subject: [PATCH 188/217] feat: render api reference schemas with JsonView
---
.../[workspace_id]/playground/components/api-reference.tsx | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx
index 05434b953..ba7312d7b 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx
@@ -2,6 +2,7 @@
import { Check, ChevronRight, Copy } from "lucide-react";
import { useMemo, useState } from "react";
+import { JsonView } from "@/components/json-view";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BACKEND_URL } from "@/lib/env-config";
@@ -51,9 +52,9 @@ function SchemaBlock({ title, schema }: { title: string; schema: Record
);
From cbf2334fb0d2ecbff578d1f6d3ae359d8572e96f Mon Sep 17 00:00:00 2001
From: CREDO23
Date: Thu, 23 Jul 2026 20:47:10 +0200
Subject: [PATCH 189/217] fix: scope runs detail scrolling to the output table
---
.../playground/components/output-viewer.tsx | 25 ++--
.../playground/components/run-detail.tsx | 9 +-
.../playground/components/runs-table.tsx | 127 +++++++++---------
3 files changed, 76 insertions(+), 85 deletions(-)
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/output-viewer.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/output-viewer.tsx
index 72262d440..106200a70 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/output-viewer.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/output-viewer.tsx
@@ -2,15 +2,9 @@
import { Check, Copy, Download } from "lucide-react";
import { useMemo, useState } from "react";
+import { JsonView } from "@/components/json-view";
import { Button } from "@/components/ui/button";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
+import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { downloadCsv, rowsToCsv } from "@/lib/playground/csv";
@@ -48,10 +42,11 @@ function ResultTable({ items }: { items: Record[] }) {
const rows = items.slice(0, MAX_TABLE_ROWS);
+ // overscroll-contain keeps table scrolling from chaining to the page.
return (
-
-
-
+
+
+
{columns.map((col) => (
@@ -72,7 +67,7 @@ function ResultTable({ items }: { items: Record[] }) {
))}
-
+
);
}
@@ -152,9 +147,9 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa
)}
>
) : (
-
- {json}
-
+
+
+
)}
);
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/run-detail.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/run-detail.tsx
index 4a128f5d8..fdf41729c 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/run-detail.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/run-detail.tsx
@@ -1,6 +1,7 @@
"use client";
import { useMemo } from "react";
+import { JsonView } from "@/components/json-view";
import { Spinner } from "@/components/ui/spinner";
import { useScraperRun } from "@/hooks/use-scraper-runs";
import { OutputViewer } from "./output-viewer";
@@ -69,7 +70,7 @@ export function RunDetail({ workspaceId, runId }: { workspaceId: number; runId:
{run.progress && run.progress.length > 0 && (
Progress
-
+
{run.progress.map((event, i) => (
{progressLine(event)}
@@ -81,9 +82,9 @@ export function RunDetail({ workspaceId, runId }: { workspaceId: number; runId:
Input
-
- {JSON.stringify(run.input ?? {}, null, 2)}
-
+
+
+
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/runs-table.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/runs-table.tsx
index 57b7a547c..0d642ac3f 100644
--- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/runs-table.tsx
+++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/runs-table.tsx
@@ -2,7 +2,7 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { ChevronDown, ChevronRight, History, Info } from "lucide-react";
-import { Fragment, useState } from "react";
+import { useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
@@ -13,14 +13,6 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
import { formatRelativeDate } from "@/lib/format-date";
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
@@ -33,6 +25,12 @@ import { RunStatusBadge } from "./run-status-badge";
const PAGE_SIZE = 25;
const ALL = "__all__";
+// A grid (not a
) keeps an expanded row's detail out of a table-cell,
+// where overflow/max-height scroll containers get ignored. Tracks are fr/fixed
+// (never content-based auto) so separate per-row grids stay column-aligned.
+const ROW_GRID =
+ "grid grid-cols-[2rem_minmax(8rem,2fr)_minmax(4rem,1fr)_minmax(5rem,1fr)_4rem_5rem_5rem_minmax(6rem,1fr)] items-center gap-2 px-3";
+
const CAPABILITY_OPTIONS = PLAYGROUND_PLATFORMS.flatMap((platform) =>
platform.verbs.map((verb) => verb.name)
);
@@ -122,63 +120,60 @@ export function RunsTable({ workspaceId }: { workspaceId: number }) {
) : (
-
-
-
-
- API
- Origin
- Status
- Items
- Duration
- Cost
- When
-
-
-
- {runs.map((run) => {
- const isOpen = expanded === run.id;
- return (
-
- setExpanded(isOpen ? null : run.id)}
- >
-
- {isOpen ? (
-
- ) : (
-
- )}
-
- {run.capability}
- {run.origin}
-
-
-
- {run.item_count}
-
- {formatDuration(run.duration_ms)}
-
-
- {formatCost(run.cost_micros)}
-
-
- {formatRelativeDate(run.created_at)}
-
-
- {isOpen && (
-
-
-
-
-
- )}
-
- );
- })}
-
-
+
+
+ API
+ Origin
+ Status
+ Items
+ Duration
+ Cost
+ When
+
+ {runs.map((run) => {
+ const isOpen = expanded === run.id;
+ return (
+
+ setExpanded(isOpen ? null : run.id)}
+ className={cn(
+ ROW_GRID,
+ "w-full py-2.5 text-left text-sm transition-colors hover:bg-muted/50"
+ )}
+ >
+ {isOpen ? (
+
+ ) : (
+
+ )}
+ {run.capability}
+
+ {run.origin}
+
+
+
+
+ {run.item_count}
+
+ {formatDuration(run.duration_ms)}
+
+
+ {formatCost(run.cost_micros)}
+
+
+ {formatRelativeDate(run.created_at)}
+
+
+ {isOpen && }
+
+ );
+ })}
)}
From 221139ec42bdc3288e04a7578583da195ffb25ab Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 24 Jul 2026 00:31:16 +0530
Subject: [PATCH 190/217] feat(composer-add-menu-drawer): implement mobile
drawer for adding connectors and managing tools
---
.../assistant-ui/composer-add-menu-drawer.tsx | 342 ++++++++++++++++++
1 file changed, 342 insertions(+)
create mode 100644 surfsense_web/components/assistant-ui/composer-add-menu-drawer.tsx
diff --git a/surfsense_web/components/assistant-ui/composer-add-menu-drawer.tsx b/surfsense_web/components/assistant-ui/composer-add-menu-drawer.tsx
new file mode 100644
index 000000000..182639911
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/composer-add-menu-drawer.tsx
@@ -0,0 +1,342 @@
+"use client";
+
+import {
+ ChevronLeft,
+ ChevronRight,
+ LayoutGrid,
+ Settings2,
+ Unplug,
+ Upload,
+ Wrench,
+} from "lucide-react";
+import Image from "next/image";
+import { type ReactNode, useCallback, useRef, useState } from "react";
+import type {
+ ConnectorTypeRow,
+} from "@/components/assistant-ui/connector-popup/constants/connector-constants";
+import { Button } from "@/components/ui/button";
+import {
+ Drawer,
+ DrawerContent,
+ DrawerHandle,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer";
+import { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Switch } from "@/components/ui/switch";
+import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
+import {
+ CONNECTOR_TOOL_ICON_PATHS,
+ getToolDisplayName,
+ getToolIcon,
+} from "@/contracts/enums/toolIcons";
+import { cn } from "@/lib/utils";
+
+/** Minimal shape of a grouped agent-tool section (mirrors thread.tsx). */
+export interface ToolGroupView {
+ label: string;
+ tools: { name: string }[];
+ connectorIcon?: string;
+}
+
+interface ComposerAddMenuDrawerProps {
+ /** The `+` button; rendered as the drawer trigger. */
+ trigger: ReactNode;
+ onUploadFiles: () => void;
+ /** Connected connectors, one row per type (see `groupConnectorsByType`). */
+ connectorRows: ConnectorTypeRow[];
+ /** Open a connector's manage view (deep-links via importConnectorRequestAtom). */
+ onSelectConnector: (row: ConnectorTypeRow) => void;
+ /** Navigate to the full connectors catalog. */
+ onBrowseConnectors: () => void;
+ regularToolGroups: ToolGroupView[];
+ connectorToolGroups: ToolGroupView[];
+ otherToolGroup?: ToolGroupView;
+ disabledToolsSet: Set;
+ onToggleTool: (name: string) => void;
+ onToggleToolGroup: (names: string[]) => void;
+ /** True while the tool list is still loading (shows a skeleton). */
+ toolsLoading: boolean;
+}
+
+/**
+ * Mobile "+" menu. A single vaul drawer that behaves like a flat list at the
+ * root and drills into submenus in place (each level replaces the previous,
+ * with a back button) rather than nesting overlays — the touch-friendly
+ * equivalent of the desktop dropdown submenus. Screen state is a small push/pop
+ * stack; closing the drawer resets it to the root.
+ */
+type Screen =
+ | { kind: "root" }
+ | { kind: "connectors" }
+ | { kind: "tools" }
+ | { kind: "toolGroup"; label: string };
+
+const ROW = "flex w-full items-center gap-3 px-4 py-3 text-sm hover:bg-accent hover:text-accent-foreground transition-colors";
+
+export function ComposerAddMenuDrawer({
+ trigger,
+ onUploadFiles,
+ connectorRows,
+ onSelectConnector,
+ onBrowseConnectors,
+ regularToolGroups,
+ connectorToolGroups,
+ otherToolGroup,
+ disabledToolsSet,
+ onToggleTool,
+ onToggleToolGroup,
+ toolsLoading,
+}: ComposerAddMenuDrawerProps) {
+ const [open, setOpen] = useState(false);
+ const [stack, setStack] = useState([{ kind: "root" }]);
+ // Slide direction: forward on push, back on pop — drives the enter animation.
+ const dirRef = useRef<"forward" | "back">("forward");
+ const current = stack[stack.length - 1];
+
+ const push = useCallback((screen: Screen) => {
+ dirRef.current = "forward";
+ setStack((prev) => [...prev, screen]);
+ }, []);
+ const pop = useCallback(() => {
+ dirRef.current = "back";
+ setStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev));
+ }, []);
+
+ const handleOpenChange = useCallback((next: boolean) => {
+ setOpen(next);
+ // Reset to root when closed so the next open starts fresh.
+ if (!next) {
+ dirRef.current = "forward";
+ setStack([{ kind: "root" }]);
+ }
+ }, []);
+
+ const close = useCallback(() => handleOpenChange(false), [handleOpenChange]);
+
+ const title =
+ current.kind === "connectors"
+ ? "MCP Connectors"
+ : current.kind === "tools"
+ ? "Manage Tools"
+ : current.kind === "toolGroup"
+ ? current.label
+ : "Add";
+
+ const renderToolRow = (name: string) => {
+ const isDisabled = disabledToolsSet.has(name);
+ const ToolIcon = getToolIcon(name);
+ return (
+
+
+ {getToolDisplayName(name)}
+ onToggleTool(name)}
+ className="shrink-0"
+ />
+
+ );
+ };
+
+ const renderBody = () => {
+ if (current.kind === "root") {
+ return (
+ <>
+ {
+ onUploadFiles();
+ close();
+ }}
+ >
+
+ Upload Files
+
+ push({ kind: "connectors" })}
+ >
+
+ MCP Connectors
+
+
+ push({ kind: "tools" })}>
+
+ Manage Tools
+
+
+ >
+ );
+ }
+
+ if (current.kind === "connectors") {
+ return (
+ <>
+ {connectorRows.length === 0 ? (
+
+ No connectors yet.
+
+ ) : (
+ connectorRows.map((row) => (
+ {
+ onSelectConnector(row);
+ close();
+ }}
+ >
+ {getConnectorIcon(row.type, "size-4 shrink-0 text-muted-foreground")}
+ {row.title}
+ {row.accountCount > 1 ? (
+ {row.accountCount}
+ ) : null}
+
+ ))
+ )}
+
+ {
+ onBrowseConnectors();
+ close();
+ }}
+ >
+
+ Browse all connectors
+
+ >
+ );
+ }
+
+ if (current.kind === "toolGroup") {
+ const group = connectorToolGroups.find((g) => g.label === current.label);
+ return <>{group?.tools.map((t) => renderToolRow(t.name))}>;
+ }
+
+ // current.kind === "tools"
+ if (toolsLoading) {
+ return (
+
+
+ {["t1", "t2", "t3", "t4"].map((k) => (
+
+
+
+
+
+ ))}
+
+ );
+ }
+
+ return (
+ <>
+ {regularToolGroups.map((group) => (
+
+
+ {group.label}
+
+ {group.tools.map((t) => renderToolRow(t.name))}
+
+ ))}
+ {connectorToolGroups.length > 0 && (
+
+
+ Connector Actions
+
+ {connectorToolGroups.map((group) => {
+ const iconInfo = CONNECTOR_TOOL_ICON_PATHS[group.connectorIcon ?? ""];
+ const toolNames = group.tools.map((t) => t.name);
+ const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
+ return (
+
+ push({ kind: "toolGroup", label: group.label })}
+ >
+ {iconInfo ? (
+
+ ) : (
+
+ )}
+
+ {group.label}
+
+
+
+ onToggleToolGroup(toolNames)}
+ className="shrink-0"
+ />
+
+ );
+ })}
+
+ )}
+ {otherToolGroup && (
+
+
+ {otherToolGroup.label}
+
+ {otherToolGroup.tools.map((t) => renderToolRow(t.name))}
+
+ )}
+ >
+ );
+ };
+
+ return (
+
+ {trigger}
+
+
+
+ {stack.length > 1 ? (
+
+
+
+ ) : (
+
+ )}
+ {title}
+
+
+
+
+
+ );
+}
From a0ce494dd97978bcccb73e40b73952529f47376b Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 24 Jul 2026 00:31:26 +0530
Subject: [PATCH 191/217] feat(composer-action): enhance connector management
with grouped rows and improved state handling
---
.../components/assistant-ui/thread.tsx | 319 +++++-------------
1 file changed, 91 insertions(+), 228 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx
index 5c31af139..c3663e3fc 100644
--- a/surfsense_web/components/assistant-ui/thread.tsx
+++ b/surfsense_web/components/assistant-ui/thread.tsx
@@ -12,8 +12,8 @@ import {
ArrowUpIcon,
Camera,
ChevronDown,
- ChevronRight,
Clipboard,
+ LayoutGrid,
Plus,
Settings2,
SquareIcon,
@@ -43,6 +43,7 @@ import {
clearPremiumAlertForThreadAtom,
premiumAlertByThreadAtom,
} from "@/atoms/chat/premium-alert.atom";
+import { importConnectorRequestAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms";
@@ -50,6 +51,11 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
import { ChatViewport } from "@/components/assistant-ui/chat-viewport";
+import { ComposerAddMenuDrawer } from "@/components/assistant-ui/composer-add-menu-drawer";
+import {
+ type ConnectorTypeRow,
+ groupConnectorsByType,
+} from "@/components/assistant-ui/connector-popup/constants/connector-constants";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import {
InlineMentionEditor,
@@ -66,19 +72,12 @@ import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
-import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
-import {
- Drawer,
- DrawerContent,
- DrawerHandle,
- DrawerHeader,
- DrawerTitle,
-} from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
+ DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
@@ -88,12 +87,14 @@ import { Popover, PopoverAnchor } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import {
CONNECTOR_ICON_TO_TYPES,
CONNECTOR_TOOL_ICON_PATHS,
getToolDisplayName,
getToolIcon,
} from "@/contracts/enums/toolIcons";
+import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsSync } from "@/hooks/use-comments-sync";
import { useMediaQuery } from "@/hooks/use-media-query";
@@ -1052,9 +1053,6 @@ const ComposerAction: FC = ({
);
const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false);
const [openConnectorSubmenu, setOpenConnectorSubmenu] = useState(null);
- const [expandedConnectorGroups, setExpandedConnectorGroups] = useState>(
- () => new Set()
- );
const isDesktop = useMediaQuery("(min-width: 640px)");
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const pendingScreenImages = useAtomValue(pendingUserImageDataUrlsAtom);
@@ -1089,6 +1087,23 @@ const ComposerAction: FC = ({
() => new Set((connectors ?? []).map((c) => c.connector_type)),
[connectors]
);
+ // "Your connectors" rows for the add-menu (desktop submenu + mobile drawer),
+ // grouped by type via the same helper the connectors panel rail uses.
+ const connectorRows = useMemo(
+ () => groupConnectorsByType((connectors ?? []) as SearchSourceConnector[]),
+ [connectors]
+ );
+ const setImportRequest = useSetAtom(importConnectorRequestAtom);
+ const openConnectorManage = useCallback(
+ (row: ConnectorTypeRow) => {
+ // Deep-link into the connector's manage view: the panel's
+ // useConnectorDialog consumes this atom and routes by account count
+ // (none -> connect, one -> edit, many -> accounts list).
+ setImportRequest({ connectorType: row.type, mode: "auto" });
+ openConnectors();
+ },
+ [setImportRequest, openConnectors]
+ );
const toggleToolGroup = useCallback(
(toolNames: string[]) => {
@@ -1101,18 +1116,6 @@ const ComposerAction: FC = ({
},
[disabledToolsSet, setDisabledTools]
);
- const setConnectorGroupExpanded = useCallback((label: string, expanded: boolean) => {
- setExpandedConnectorGroups((prev) => {
- const next = new Set(prev);
- if (expanded) {
- next.add(label);
- } else {
- next.delete(label);
- }
- return next;
- });
- }, []);
-
const filteredTools = agentTools;
const groupedTools = useMemo(() => {
if (!filteredTools) return [];
@@ -1181,206 +1184,30 @@ const ComposerAction: FC = ({
{!isDesktop ? (
- <>
-
-
-
-
-
-
-
- openUploadDialog()}>
-
- Upload Files
-
-
-
- MCP Connectors
-
- setToolsPopoverOpen(true)}>
-
- Manage Tools
-
-
-
-
-
-
-
-
- Manage Tools
-
-
-
- {regularToolGroups.map((group) => (
-
-
- {group.label}
-
- {group.tools.map((tool) => {
- const isDisabled = disabledToolsSet.has(tool.name);
- const ToolIcon = getToolIcon(tool.name);
- return (
-
-
-
- {formatToolName(tool.name)}
-
- toggleTool(tool.name)}
- className="shrink-0"
- />
-
- );
- })}
-
- ))}
- {connectorToolGroups.length > 0 && (
-
-
- Connector Actions
-
- {connectorToolGroups.map((group) => {
- const iconKey = group.connectorIcon ?? "";
- const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
- const toolNames = group.tools.map((t) => t.name);
- const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
- const isExpanded = expandedConnectorGroups.has(group.label);
- return (
-
setConnectorGroupExpanded(group.label, open)}
- >
-
-
-
- {iconInfo ? (
-
- ) : (
-
- )}
-
- {group.label}
-
- {isExpanded ? (
-
- ) : (
-
- )}
-
-
- toggleToolGroup(toolNames)}
- className="shrink-0"
- />
-
-
- {group.tools.map((tool) => {
- const isDisabled = disabledToolsSet.has(tool.name);
- return (
-
-
- {formatToolName(tool.name)}
-
- toggleTool(tool.name)}
- className="shrink-0"
- />
-
- );
- })}
-
-
- );
- })}
-
- )}
- {otherToolGroup && (
-
-
- {otherToolGroup.label}
-
- {otherToolGroup.tools.map((tool) => {
- const isDisabled = disabledToolsSet.has(tool.name);
- const ToolIcon = getToolIcon(tool.name);
- return (
-
-
-
- {formatToolName(tool.name)}
-
- toggleTool(tool.name)}
- className="shrink-0"
- />
-
- );
- })}
-
- )}
- {!filteredTools?.length && (
-
-
- {["t1", "t2", "t3", "t4"].map((k) => (
-
-
-
-
-
- ))}
-
- {["c1", "c2", "c3"].map((k) => (
-
-
-
-
-
- ))}
-
- )}
-
-
-
- >
+
+
+
+ }
+ onUploadFiles={() => openUploadDialog()}
+ connectorRows={connectorRows}
+ onSelectConnector={openConnectorManage}
+ onBrowseConnectors={openConnectors}
+ regularToolGroups={regularToolGroups}
+ connectorToolGroups={connectorToolGroups}
+ otherToolGroup={otherToolGroup}
+ disabledToolsSet={disabledToolsSet}
+ onToggleTool={toggleTool}
+ onToggleToolGroup={toggleToolGroup}
+ toolsLoading={!filteredTools?.length}
+ />
) : (
{
@@ -1419,10 +1246,46 @@ const ComposerAction: FC = ({
Take a screenshot
-
-
- MCP Connectors
-
+ {connectorRows.length > 0 ? (
+
+
+
+ MCP Connectors
+
+
+
+ {connectorRows.map((row) => (
+ openConnectorManage(row)}
+ className="gap-2"
+ >
+ {getConnectorIcon(row.type, "h-4 w-4")}
+ {row.title}
+ {row.accountCount > 1 ? (
+
+ {row.accountCount}
+
+ ) : null}
+
+ ))}
+
+
+
+ Browse all connectors
+
+
+
+
+ ) : (
+
+
+ MCP Connectors
+
+ )}
{
From 16d25a9aac6524e7decaedfce9544c2b60b53dca Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 24 Jul 2026 00:31:41 +0530
Subject: [PATCH 192/217] feat(connectors-panel): integrate input component and
refactor connector grouping logic for improved search functionality
---
.../connector-popup/connectors-panel.tsx | 70 ++++++++-----------
1 file changed, 29 insertions(+), 41 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connectors-panel.tsx b/surfsense_web/components/assistant-ui/connector-popup/connectors-panel.tsx
index 4e0e8b4b1..1745e15ed 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connectors-panel.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connectors-panel.tsx
@@ -13,6 +13,7 @@ import {
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
+import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector";
@@ -25,7 +26,13 @@ import { cn } from "@/lib/utils";
import { ConnectorConnectView } from "./connector-configs/views/connector-connect-view";
import { ConnectorEditView } from "./connector-configs/views/connector-edit-view";
import { IndexingConfigurationView } from "./connector-configs/views/indexing-configuration-view";
-import { COMPOSIO_CONNECTORS, getConnectorTitle, OAUTH_CONNECTORS } from "./constants/connector-constants";
+import {
+ COMPOSIO_CONNECTORS,
+ type ConnectorTypeRow,
+ getConnectorTitle,
+ groupConnectorsByType,
+ OAUTH_CONNECTORS,
+} from "./constants/connector-constants";
import { useConnectorDialog } from "./hooks/use-connector-dialog";
import { useIndexingConnectors } from "./hooks/use-indexing-connectors";
import { AllConnectorsTab } from "./tabs/all-connectors-tab";
@@ -35,11 +42,7 @@ import { YouTubeCrawlerView } from "./views/youtube-crawler-view";
/** Health of a connected connector type, derived from indexing + inbox state. */
type ConnectorHealth = "syncing" | "failed" | "ok";
-interface ConnectedRow {
- type: string;
- title: string;
- connectors: SearchSourceConnector[];
- accountCount: number;
+interface ConnectedRow extends ConnectorTypeRow {
health: ConnectorHealth;
errorMessage?: string;
}
@@ -171,32 +174,20 @@ export function ConnectorsSection() {
// Flat "Your connectors" rows: one per connected type, alphabetized, with a
// derived health (syncing > failed > ok) and account count.
const connectedRows = useMemo(() => {
- const byType = new Map();
- for (const c of connectors) {
- const arr = byType.get(c.connector_type) ?? [];
- arr.push(c);
- byType.set(c.connector_type, arr);
- }
- return [...byType.entries()]
- .map(([type, list]) => {
- const ids = list.map((c) => c.id);
- const syncing = ids.some(
- (id) => indexingConnectorIds.has(id) || statusByConnectorId.get(id)?.status === "in_progress"
- );
- const failedEntry = syncing
- ? undefined
- : ids.map((id) => statusByConnectorId.get(id)).find((s) => s?.status === "failed");
- return {
- type,
- title:
- type === EnumConnectorName.MCP_CONNECTOR ? "MCP Servers" : getConnectorTitle(type),
- connectors: list,
- accountCount: list.length,
- health: syncing ? "syncing" : failedEntry ? "failed" : "ok",
- errorMessage: failedEntry?.error,
- } satisfies ConnectedRow;
- })
- .sort((a, b) => a.title.localeCompare(b.title));
+ return groupConnectorsByType(connectors).map((row) => {
+ const ids = row.connectors.map((c) => c.id);
+ const syncing = ids.some(
+ (id) => indexingConnectorIds.has(id) || statusByConnectorId.get(id)?.status === "in_progress"
+ );
+ const failedEntry = syncing
+ ? undefined
+ : ids.map((id) => statusByConnectorId.get(id)).find((s) => s?.status === "failed");
+ return {
+ ...row,
+ health: syncing ? "syncing" : failedEntry ? "failed" : "ok",
+ errorMessage: failedEntry?.error,
+ } satisfies ConnectedRow;
+ });
}, [connectors, indexingConnectorIds, statusByConnectorId]);
if (!workspaceId) return null;
@@ -461,17 +452,14 @@ export function ConnectorsSection() {
const catalog = (
-
+
-
-
+ setSearchQuery(e.target.value)}
/>
@@ -481,10 +469,10 @@ export function ConnectorsSection() {
size="icon"
type="button"
onClick={() => setSearchQuery("")}
- className="absolute right-1.5 top-1/2 size-7 -translate-y-1/2 text-muted-foreground transition-colors hover:bg-transparent hover:text-accent-foreground"
+ className="absolute right-2 top-1/2 h-7 w-7 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label="Clear search"
>
-
+
)}
From 45c5309dbbf38f8d446343b3e62dc72849724085 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 24 Jul 2026 00:31:49 +0530
Subject: [PATCH 193/217] feat(connector-constants): add grouping function for
connectors by type and define ConnectorTypeRow interface
---
.../constants/connector-constants.ts | 32 +++++++++++++++++++
1 file changed, 32 insertions(+)
diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts
index a91fa4d9f..6654c934e 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts
+++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts
@@ -1,4 +1,5 @@
import { EnumConnectorName } from "@/contracts/enums/connector";
+import type { SearchSourceConnector } from "@/contracts/types/connector.types";
/**
* Connectors retired during the MCP migration (no viable official MCP server).
@@ -278,6 +279,37 @@ export function getConnectorTitle(connectorType: string): string {
);
}
+/** One row per connected connector type (multiple accounts collapsed into one). */
+export interface ConnectorTypeRow {
+ type: string;
+ title: string;
+ connectors: SearchSourceConnector[];
+ accountCount: number;
+}
+
+/**
+ * Collapse a flat connector list into one row per `connector_type`, titled for
+ * display (MCP collapses to "MCP Servers") and sorted alphabetically. Shared by
+ * the connectors panel's "Your connectors" rail and the composer add-menu so the
+ * two lists never drift; callers layer their own extras (e.g. health) on top.
+ */
+export function groupConnectorsByType(connectors: SearchSourceConnector[]): ConnectorTypeRow[] {
+ const byType = new Map
();
+ for (const c of connectors) {
+ const arr = byType.get(c.connector_type) ?? [];
+ arr.push(c);
+ byType.set(c.connector_type, arr);
+ }
+ return [...byType.entries()]
+ .map(([type, list]) => ({
+ type,
+ title: type === EnumConnectorName.MCP_CONNECTOR ? "MCP Servers" : getConnectorTitle(type),
+ connectors: list,
+ accountCount: list.length,
+ }))
+ .sort((a, b) => a.title.localeCompare(b.title));
+}
+
// Composio Toolkits (available integrations via Composio)
export const COMPOSIO_TOOLKITS = [
{
From 828b2733e5a2dc1a15523b0bd65007ddd1667ec1 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 24 Jul 2026 00:48:56 +0530
Subject: [PATCH 194/217] feat(connector-rows): implement useConnectorRows hook
for managing connector health and indexing state
---
.../hooks/use-connector-rows.ts | 78 +++++++++++++++++++
1 file changed, 78 insertions(+)
create mode 100644 surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-rows.ts
diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-rows.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-rows.ts
new file mode 100644
index 000000000..e5b1052cf
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-rows.ts
@@ -0,0 +1,78 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { useMemo } from "react";
+import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
+import type { SearchSourceConnector } from "@/contracts/types/connector.types";
+import { connectorIndexingMetadata } from "@/contracts/types/inbox.types";
+import { type ConnectorTypeRow, groupConnectorsByType } from "../constants/connector-constants";
+import { useIndexingConnectors } from "./use-indexing-connectors";
+
+/** Health of a connected connector type, derived from indexing + inbox state. */
+export type ConnectorHealth = "syncing" | "failed" | "ok";
+
+/** A grouped connector row enriched with live indexing health. */
+export interface ConnectorRow extends ConnectorTypeRow {
+ health: ConnectorHealth;
+ errorMessage?: string;
+}
+
+/**
+ * Single source of truth for the "Your connectors" list: groups the connectors
+ * by type and derives each row's health (syncing > failed > ok) from the status
+ * inbox and optimistic indexing state. Shared by the connectors panel rail and
+ * the composer add-menu so both render identical status glyphs.
+ *
+ * Also returns the underlying indexing controls so the panel can reuse them for
+ * its flow views instead of instantiating `useIndexingConnectors` twice.
+ */
+export function useConnectorRows(connectors: SearchSourceConnector[]) {
+ const statusInboxItems = useAtomValue(statusInboxItemsAtom);
+ const inboxItems = useMemo(
+ () => statusInboxItems.filter((item) => item.type === "connector_indexing"),
+ [statusInboxItems]
+ );
+
+ const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
+ connectors,
+ inboxItems
+ );
+
+ // Latest indexing status per connector id, parsed from the status inbox.
+ const statusByConnectorId = useMemo(() => {
+ const map = new Map();
+ for (const item of inboxItems) {
+ const parsed = connectorIndexingMetadata.safeParse(item.metadata);
+ if (!parsed.success) continue;
+ const at = item.updated_at ?? item.created_at;
+ const prev = map.get(parsed.data.connector_id);
+ if (!prev || at > prev.at) {
+ map.set(parsed.data.connector_id, {
+ status: parsed.data.status,
+ error: parsed.data.error_message ?? undefined,
+ at,
+ });
+ }
+ }
+ return map;
+ }, [inboxItems]);
+
+ const rows = useMemo(() => {
+ return groupConnectorsByType(connectors).map((row) => {
+ const ids = row.connectors.map((c) => c.id);
+ const syncing = ids.some(
+ (id) => indexingConnectorIds.has(id) || statusByConnectorId.get(id)?.status === "in_progress"
+ );
+ const failedEntry = syncing
+ ? undefined
+ : ids.map((id) => statusByConnectorId.get(id)).find((s) => s?.status === "failed");
+ return {
+ ...row,
+ health: syncing ? "syncing" : failedEntry ? "failed" : "ok",
+ errorMessage: failedEntry?.error,
+ } satisfies ConnectorRow;
+ });
+ }, [connectors, indexingConnectorIds, statusByConnectorId]);
+
+ return { rows, indexingConnectorIds, startIndexing, stopIndexing, inboxItems };
+}
From c676a17ac7b1ad3e2809c8413a92b8088ff9c971 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 24 Jul 2026 00:49:10 +0530
Subject: [PATCH 195/217] refactor(composer-add-menu-drawer): update connector
row types and enhance health indicators with spinner and alert components
---
.../assistant-ui/composer-add-menu-drawer.tsx | 21 ++++++++++++-------
1 file changed, 14 insertions(+), 7 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/composer-add-menu-drawer.tsx b/surfsense_web/components/assistant-ui/composer-add-menu-drawer.tsx
index 182639911..4b97a1aaf 100644
--- a/surfsense_web/components/assistant-ui/composer-add-menu-drawer.tsx
+++ b/surfsense_web/components/assistant-ui/composer-add-menu-drawer.tsx
@@ -5,15 +5,14 @@ import {
ChevronRight,
LayoutGrid,
Settings2,
+ TriangleAlert,
Unplug,
Upload,
Wrench,
} from "lucide-react";
import Image from "next/image";
import { type ReactNode, useCallback, useRef, useState } from "react";
-import type {
- ConnectorTypeRow,
-} from "@/components/assistant-ui/connector-popup/constants/connector-constants";
+import type { ConnectorRow } from "@/components/assistant-ui/connector-popup/hooks/use-connector-rows";
import { Button } from "@/components/ui/button";
import {
Drawer,
@@ -25,6 +24,7 @@ import {
} from "@/components/ui/drawer";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
+import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import {
@@ -45,10 +45,10 @@ interface ComposerAddMenuDrawerProps {
/** The `+` button; rendered as the drawer trigger. */
trigger: ReactNode;
onUploadFiles: () => void;
- /** Connected connectors, one row per type (see `groupConnectorsByType`). */
- connectorRows: ConnectorTypeRow[];
+ /** Connected connectors: one row per type, with live indexing health (`useConnectorRows`). */
+ connectorRows: ConnectorRow[];
/** Open a connector's manage view (deep-links via importConnectorRequestAtom). */
- onSelectConnector: (row: ConnectorTypeRow) => void;
+ onSelectConnector: (row: ConnectorRow) => void;
/** Navigate to the full connectors catalog. */
onBrowseConnectors: () => void;
regularToolGroups: ToolGroupView[];
@@ -194,7 +194,14 @@ export function ComposerAddMenuDrawer({
>
{getConnectorIcon(row.type, "size-4 shrink-0 text-muted-foreground")}
{row.title}
- {row.accountCount > 1 ? (
+ {row.health === "syncing" ? (
+
+ ) : row.health === "failed" ? (
+
+ ) : row.accountCount > 1 ? (
{row.accountCount}
) : null}
From 7fba0c346e1106b83ae03523bb6f246af2766d83 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 24 Jul 2026 00:49:18 +0530
Subject: [PATCH 196/217] refactor(composer-action): streamline connector row
handling and enhance health indicators with spinner and alert components
---
.../components/assistant-ui/thread.tsx | 28 +++++++++++--------
1 file changed, 17 insertions(+), 11 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx
index c3663e3fc..50eadd237 100644
--- a/surfsense_web/components/assistant-ui/thread.tsx
+++ b/surfsense_web/components/assistant-ui/thread.tsx
@@ -17,6 +17,7 @@ import {
Plus,
Settings2,
SquareIcon,
+ TriangleAlert,
Unplug,
Upload,
Wrench,
@@ -53,9 +54,9 @@ import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status
import { ChatViewport } from "@/components/assistant-ui/chat-viewport";
import { ComposerAddMenuDrawer } from "@/components/assistant-ui/composer-add-menu-drawer";
import {
- type ConnectorTypeRow,
- groupConnectorsByType,
-} from "@/components/assistant-ui/connector-popup/constants/connector-constants";
+ type ConnectorRow,
+ useConnectorRows,
+} from "@/components/assistant-ui/connector-popup/hooks/use-connector-rows";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import {
InlineMentionEditor,
@@ -85,6 +86,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Popover, PopoverAnchor } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
+import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
@@ -1087,15 +1089,12 @@ const ComposerAction: FC = ({
() => new Set((connectors ?? []).map((c) => c.connector_type)),
[connectors]
);
- // "Your connectors" rows for the add-menu (desktop submenu + mobile drawer),
- // grouped by type via the same helper the connectors panel rail uses.
- const connectorRows = useMemo(
- () => groupConnectorsByType((connectors ?? []) as SearchSourceConnector[]),
- [connectors]
- );
+ // "Your connectors" rows for the add-menu (desktop submenu + mobile drawer):
+ // same hook the connectors panel rail uses, so grouping + health glyphs match.
+ const { rows: connectorRows } = useConnectorRows((connectors ?? []) as SearchSourceConnector[]);
const setImportRequest = useSetAtom(importConnectorRequestAtom);
const openConnectorManage = useCallback(
- (row: ConnectorTypeRow) => {
+ (row: ConnectorRow) => {
// Deep-link into the connector's manage view: the panel's
// useConnectorDialog consumes this atom and routes by account count
// (none -> connect, one -> edit, many -> accounts list).
@@ -1265,7 +1264,14 @@ const ComposerAction: FC = ({
>
{getConnectorIcon(row.type, "h-4 w-4")}
{row.title}
- {row.accountCount > 1 ? (
+ {row.health === "syncing" ? (
+
+ ) : row.health === "failed" ? (
+
+ ) : row.accountCount > 1 ? (
{row.accountCount}
From 1df5b843acb7c2b5d3025876cba386ab3ca74c5a Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 24 Jul 2026 00:49:29 +0530
Subject: [PATCH 197/217] refactor(connectors-panel): remove unused imports and
simplify connector row management with useConnectorRows hook
---
.../connector-popup/connectors-panel.tsx | 73 +++----------------
1 file changed, 10 insertions(+), 63 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connectors-panel.tsx b/surfsense_web/components/assistant-ui/connector-popup/connectors-panel.tsx
index 1745e15ed..36c735d07 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connectors-panel.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connectors-panel.tsx
@@ -3,7 +3,6 @@
import { useAtomValue } from "jotai";
import { ChevronRight, LayoutGrid, Search, TriangleAlert, X } from "lucide-react";
import { type ReactNode, useMemo, useState } from "react";
-import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
import { Button } from "@/components/ui/button";
import {
@@ -19,7 +18,6 @@ import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
-import { connectorIndexingMetadata } from "@/contracts/types/inbox.types";
import { useConnectorsSync } from "@/hooks/use-connectors-sync";
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
import { cn } from "@/lib/utils";
@@ -28,25 +26,15 @@ import { ConnectorEditView } from "./connector-configs/views/connector-edit-view
import { IndexingConfigurationView } from "./connector-configs/views/indexing-configuration-view";
import {
COMPOSIO_CONNECTORS,
- type ConnectorTypeRow,
getConnectorTitle,
- groupConnectorsByType,
OAUTH_CONNECTORS,
} from "./constants/connector-constants";
+import { type ConnectorRow, useConnectorRows } from "./hooks/use-connector-rows";
import { useConnectorDialog } from "./hooks/use-connector-dialog";
-import { useIndexingConnectors } from "./hooks/use-indexing-connectors";
import { AllConnectorsTab } from "./tabs/all-connectors-tab";
import { ConnectorAccountsListView } from "./views/connector-accounts-list-view";
import { YouTubeCrawlerView } from "./views/youtube-crawler-view";
-/** Health of a connected connector type, derived from indexing + inbox state. */
-type ConnectorHealth = "syncing" | "failed" | "ok";
-
-interface ConnectedRow extends ConnectorTypeRow {
- health: ConnectorHealth;
- errorMessage?: string;
-}
-
/**
* Connector management surface (single `/connectors` route) rendered inside the
* workspace panel. Stateful master–detail:
@@ -63,14 +51,8 @@ interface ConnectedRow extends ConnectorTypeRow {
export function ConnectorsSection() {
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
const documentTypeCounts = useZeroDocumentTypeCounts(workspaceId);
- const statusInboxItems = useAtomValue(statusInboxItemsAtom);
const [drawerOpen, setDrawerOpen] = useState(false);
- const inboxItems = useMemo(
- () => statusInboxItems.filter((item) => item.type === "connector_indexing"),
- [statusInboxItems]
- );
-
const {
connectingId,
searchQuery,
@@ -141,55 +123,20 @@ export function ConnectorsSection() {
}
};
- const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
- connectors,
- inboxItems
- );
+ // "Your connectors" rows with live indexing health, plus the shared indexing
+ // controls the flow views reuse (single source of truth via useConnectorRows).
+ const {
+ rows: connectedRows,
+ indexingConnectorIds,
+ startIndexing,
+ stopIndexing,
+ } = useConnectorRows(connectors);
const connectedTypes = useMemo(
() => new Set(connectors.map((c) => c.connector_type)),
[connectors]
);
- // Latest indexing status per connector id, parsed from the status inbox. Used
- // to drive the "Your connectors" status glyphs (syncing / failed).
- const statusByConnectorId = useMemo(() => {
- const map = new Map