- Unlimited seats & sites.{" "}
- Team members, websites, and API access are unlimited on every plan.
- Overage is tiered with lower rates as volume increases.
+ Unlimited seats & sites. Team
+ members, websites, and API access are unlimited on every plan. Overage
+ is tiered with lower rates as volume increases.
From f236aad956348fa732b6db899f7fbffbf33f7240 Mon Sep 17 00:00:00 2001
From: iza <59828082+izadoesdev@users.noreply.github.com>
Date: Sat, 30 May 2026 21:05:39 +0300
Subject: [PATCH 17/34] fix(api): use namespace import for zod in Autumn
webhook
---
apps/api/src/routes/webhooks/autumn.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/api/src/routes/webhooks/autumn.ts b/apps/api/src/routes/webhooks/autumn.ts
index 690405601..c71865177 100644
--- a/apps/api/src/routes/webhooks/autumn.ts
+++ b/apps/api/src/routes/webhooks/autumn.ts
@@ -21,7 +21,8 @@ import { Elysia } from "elysia";
import { useLogger } from "evlog/elysia";
import { Resend } from "resend";
import { Webhook } from "svix";
-import { z } from "zod";
+// biome-ignore lint/performance/noNamespaceImport: vitest+bun fails to bind zod's named `z` export; namespace import is the reliable form
+import * as z from "zod";
import { mergeWideEvent } from "../../lib/tracing";
const COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
From cfbe7c3c33a737bbd94599bb15b9d8c7abd2bd37 Mon Sep 17 00:00:00 2001
From: iza <59828082+izadoesdev@users.noreply.github.com>
Date: Sat, 30 May 2026 21:05:39 +0300
Subject: [PATCH 18/34] feat(db): scope agent chats to organization
Make agent_chats.website_id nullable, index by organization instead of
website, and switch the website FK to ON DELETE SET NULL so chats survive
website deletion. Agent chats are now an organization-level workspace.
---
packages/db/src/drizzle/schema/agent.ts | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/packages/db/src/drizzle/schema/agent.ts b/packages/db/src/drizzle/schema/agent.ts
index 284c2fb02..413182c85 100644
--- a/packages/db/src/drizzle/schema/agent.ts
+++ b/packages/db/src/drizzle/schema/agent.ts
@@ -58,7 +58,7 @@ export const agentChats = pgTable(
"agent_chats",
{
id: text().primaryKey(),
- websiteId: text("website_id").notNull(),
+ websiteId: text("website_id"),
userId: text("user_id").notNull(),
organizationId: text("organization_id"),
title: text().notNull().default(""),
@@ -72,8 +72,8 @@ export const agentChats = pgTable(
.$onUpdate(() => new Date()),
},
(table) => [
- index("agent_chats_website_user_updated_idx").on(
- table.websiteId,
+ index("agent_chats_org_user_updated_idx").on(
+ table.organizationId,
table.userId,
table.updatedAt.desc()
),
@@ -85,7 +85,7 @@ export const agentChats = pgTable(
columns: [table.websiteId],
foreignColumns: [websites.id],
name: "agent_chats_website_id_fkey",
- }).onDelete("cascade"),
+ }).onDelete("set null"),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
From db05d7a6becf91038e22308eda1f36b0996a6e29 Mon Sep 17 00:00:00 2001
From: iza <59828082+izadoesdev@users.noreply.github.com>
Date: Sat, 30 May 2026 21:05:40 +0300
Subject: [PATCH 19/34] feat(rpc): list agent chats by organization
List and authorize agent chats against the organization workspace rather
than a single website, and tolerate chats with a null website.
---
packages/rpc/src/routers/agent-chats.ts | 53 +++++++++++++++----------
1 file changed, 31 insertions(+), 22 deletions(-)
diff --git a/packages/rpc/src/routers/agent-chats.ts b/packages/rpc/src/routers/agent-chats.ts
index bc60fabe4..4c7790dd1 100644
--- a/packages/rpc/src/routers/agent-chats.ts
+++ b/packages/rpc/src/routers/agent-chats.ts
@@ -4,11 +4,14 @@ import { getActiveStream } from "@databuddy/redis/stream-buffer";
import { z } from "zod";
import { rpcError } from "../errors";
import { sessionProcedure, trackedSessionProcedure } from "../orpc";
-import { withWorkspace } from "../procedures/with-workspace";
+import {
+ withWorkspace,
+ workspaceInputSchema,
+} from "../procedures/with-workspace";
const chatListItemSchema = z.object({
id: z.string(),
- websiteId: z.string(),
+ websiteId: z.string().nullable(),
title: z.string(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
@@ -58,14 +61,14 @@ export const agentChatsRouter = {
.route({
method: "POST",
path: "/agent-chats/list",
- summary: "List agent chats for the current user and website",
+ summary: "List agent chats for the current user and organization",
tags: ["AgentChats"],
})
- .input(z.object({ websiteId: z.string() }))
+ .input(workspaceInputSchema)
.output(z.array(chatListItemSchema))
.handler(async ({ context, input }) => {
- await withWorkspace(context, {
- websiteId: input.websiteId,
+ const workspace = await withWorkspace(context, {
+ organizationId: input.organizationId,
permissions: ["read"],
});
@@ -81,7 +84,7 @@ export const agentChatsRouter = {
.where(
and(
eq(agentChats.userId, context.user.id),
- eq(agentChats.websiteId, input.websiteId)
+ eq(agentChats.organizationId, workspace.organizationId)
)
)
.orderBy(desc(agentChats.updatedAt))
@@ -108,12 +111,14 @@ export const agentChatsRouter = {
return null;
}
- await withWorkspace(context, {
- websiteId: row.websiteId,
- permissions: ["read"],
- });
+ if (row.organizationId) {
+ await withWorkspace(context, {
+ organizationId: row.organizationId,
+ permissions: ["read"],
+ });
+ }
- const activeStreamId = await getActiveStream(row.websiteId, row.id);
+ const activeStreamId = await getActiveStream(row.userId, row.id);
return {
id: row.id,
@@ -143,17 +148,19 @@ export const agentChatsRouter = {
.handler(async ({ context, input }) => {
const row = await context.db.query.agentChats.findFirst({
where: { id: input.id, userId: context.user.id },
- columns: { id: true, websiteId: true },
+ columns: { id: true, organizationId: true },
});
if (!row) {
throw rpcError.notFound("agent chat", input.id);
}
- await withWorkspace(context, {
- websiteId: row.websiteId,
- permissions: ["update"],
- });
+ if (row.organizationId) {
+ await withWorkspace(context, {
+ organizationId: row.organizationId,
+ permissions: ["read"],
+ });
+ }
await context.db
.update(agentChats)
@@ -175,17 +182,19 @@ export const agentChatsRouter = {
.handler(async ({ context, input }) => {
const row = await context.db.query.agentChats.findFirst({
where: { id: input.id, userId: context.user.id },
- columns: { id: true, websiteId: true },
+ columns: { id: true, organizationId: true },
});
if (!row) {
throw rpcError.notFound("agent chat", input.id);
}
- await withWorkspace(context, {
- websiteId: row.websiteId,
- permissions: ["delete"],
- });
+ if (row.organizationId) {
+ await withWorkspace(context, {
+ organizationId: row.organizationId,
+ permissions: ["read"],
+ });
+ }
await context.db.delete(agentChats).where(eq(agentChats.id, input.id));
From 350c91e3a3e5098221f4dac7a284c928ad2f6001 Mon Sep 17 00:00:00 2001
From: iza <59828082+izadoesdev@users.noreply.github.com>
Date: Sat, 30 May 2026 21:05:51 +0300
Subject: [PATCH 20/34] feat(ai): give the agent multi-website workspace
context
Add a list-websites tool and thread accessible-website context (accessible
websites + default website id) through the toolkit so the agent can query
across an organization's sites instead of a single hard-coded website.
---
packages/ai/src/ai/agents/analytics.ts | 3 ++
packages/ai/src/ai/agents/types.ts | 7 ++-
packages/ai/src/ai/config/context.ts | 54 ++++++++++++++++---
.../ai/src/ai/insights/business-context.ts | 4 +-
packages/ai/src/ai/insights/flag-context.ts | 7 +--
packages/ai/src/ai/insights/ops-context.ts | 4 +-
.../ai/src/ai/insights/product-context.ts | 4 +-
packages/ai/src/ai/prompts/analytics.ts | 31 ++++++++++-
packages/ai/src/ai/tools/dashboard-actions.ts | 3 +-
packages/ai/src/ai/tools/execute-sql-query.ts | 20 +++++--
packages/ai/src/ai/tools/get-data.ts | 37 +++++++++++--
.../ai/src/ai/tools/investigation-tools.ts | 4 +-
packages/ai/src/ai/tools/list-websites.ts | 22 ++++++++
packages/ai/src/ai/tools/profiles.ts | 48 ++++++++++++-----
packages/ai/src/ai/tools/scrape-page.ts | 26 +++++++--
packages/ai/src/ai/tools/search-console.ts | 32 +++++++++--
packages/ai/src/ai/tools/toolkit.ts | 4 +-
packages/ai/src/ai/tools/utils/context.ts | 41 ++++++++++++++
packages/ai/src/ai/tools/utils/index.ts | 6 ++-
19 files changed, 302 insertions(+), 55 deletions(-)
create mode 100644 packages/ai/src/ai/tools/list-websites.ts
diff --git a/packages/ai/src/ai/agents/analytics.ts b/packages/ai/src/ai/agents/analytics.ts
index b2f8010d8..a9ecb44cf 100644
--- a/packages/ai/src/ai/agents/analytics.ts
+++ b/packages/ai/src/ai/agents/analytics.ts
@@ -41,6 +41,9 @@ export function createConfig(
userId: context.userId,
websiteId: context.websiteId,
websiteDomain: context.websiteDomain,
+ defaultWebsiteId: context.defaultWebsiteId ?? context.websiteId,
+ accessibleWebsites: context.accessibleWebsites,
+ organizationId: context.organizationId,
timezone: context.timezone,
currentDateTime: new Date().toISOString(),
chatId: context.chatId,
diff --git a/packages/ai/src/ai/agents/types.ts b/packages/ai/src/ai/agents/types.ts
index 93f6f930c..1d0ca4ed8 100644
--- a/packages/ai/src/ai/agents/types.ts
+++ b/packages/ai/src/ai/agents/types.ts
@@ -5,6 +5,7 @@ import type {
ToolLoopAgent,
ToolSet,
} from "ai";
+import type { WebsiteSummary } from "../../lib/accessible-websites";
type ProviderOptions = NonNullable<
ConstructorParameters[0]["providerOptions"]
@@ -28,15 +29,17 @@ export const AGENT_TIERS: readonly AgentTier[] = [
] as const;
export interface AgentContext {
+ accessibleWebsites?: WebsiteSummary[];
billingCustomerId?: string | null;
chatId: string;
+ defaultWebsiteId?: string | null;
organizationId?: string;
requestHeaders?: Headers;
thinking?: AgentThinking;
timezone: string;
userId: string;
- websiteDomain: string;
- websiteId: string;
+ websiteDomain?: string;
+ websiteId?: string;
}
export interface AgentConfig {
diff --git a/packages/ai/src/ai/config/context.ts b/packages/ai/src/ai/config/context.ts
index 8a956d41e..3a5adfd5c 100644
--- a/packages/ai/src/ai/config/context.ts
+++ b/packages/ai/src/ai/config/context.ts
@@ -1,3 +1,5 @@
+import type { WebsiteSummary } from "../../lib/accessible-websites";
+
export type AppMutationMode = "allow" | "dry-run";
export interface ServiceAuth {
@@ -6,25 +8,61 @@ export interface ServiceAuth {
}
export interface AppContext {
+ accessibleWebsites?: WebsiteSummary[];
billingCustomerId?: string | null;
chatId: string;
currentDateTime: string;
+ defaultWebsiteId?: string | null;
mutationMode?: AppMutationMode;
organizationId?: string | null;
requestHeaders?: Headers;
serviceAuth?: ServiceAuth;
timezone: string;
userId: string;
- websiteDomain: string;
- websiteId: string;
+ websiteDomain?: string;
+ websiteId?: string;
[key: string]: unknown;
}
+export function requireWebsiteId(context: AppContext): string {
+ const websiteId = context.defaultWebsiteId ?? context.websiteId;
+ if (!websiteId) {
+ throw new Error("This operation requires a website in context.");
+ }
+ return websiteId;
+}
+
+function escapeAttr(value: string): string {
+ return value.replace(/"/g, """);
+}
+
export function formatContextForLLM(context: AppContext): string {
- return `
-${context.currentDateTime}
-${context.timezone}
-${context.websiteId}
-${context.websiteDomain}
-`;
+ const lines = [
+ `${context.currentDateTime}`,
+ `${context.timezone}`,
+ ];
+
+ const websites = context.accessibleWebsites ?? [];
+ if (websites.length > 0) {
+ const rows = websites
+ .map((w) => {
+ const domain = w.domain ? ` domain="${escapeAttr(w.domain)}"` : "";
+ const name = w.name ? ` name="${escapeAttr(w.name)}"` : "";
+ return ` `;
+ })
+ .join("\n");
+ lines.push(`\n${rows}\n`);
+ }
+
+ const defaultId = context.defaultWebsiteId ?? context.websiteId;
+ if (defaultId) {
+ lines.push(`${defaultId}`);
+ if (context.websiteDomain) {
+ lines.push(
+ `${context.websiteDomain}`
+ );
+ }
+ }
+
+ return `\n${lines.join("\n")}\n`;
}
diff --git a/packages/ai/src/ai/insights/business-context.ts b/packages/ai/src/ai/insights/business-context.ts
index fe6eb6ae7..f1007c09d 100644
--- a/packages/ai/src/ai/insights/business-context.ts
+++ b/packages/ai/src/ai/insights/business-context.ts
@@ -1,4 +1,4 @@
-import type { AppContext } from "../config/context";
+import { type AppContext, requireWebsiteId } from "../config/context";
import { callRPCProcedure } from "../tools/utils";
import { executeQuery } from "../../query";
import type { QueryRequest } from "../../query/types";
@@ -28,7 +28,7 @@ function runQuery(
) {
return executeQuery(
{
- projectId: appContext.websiteId,
+ projectId: requireWebsiteId(appContext),
type,
from: range.from,
to: range.to,
diff --git a/packages/ai/src/ai/insights/flag-context.ts b/packages/ai/src/ai/insights/flag-context.ts
index 0767a8ecb..2e30efd93 100644
--- a/packages/ai/src/ai/insights/flag-context.ts
+++ b/packages/ai/src/ai/insights/flag-context.ts
@@ -1,6 +1,6 @@
import { and, db, desc, eq, gte, isNull, lte, or } from "@databuddy/db";
import { flagChangeEvents } from "@databuddy/db/schema";
-import type { AppContext } from "../config/context";
+import { type AppContext, requireWebsiteId } from "../config/context";
import dayjs from "dayjs";
import timezonePlugin from "dayjs/plugin/timezone";
import utcPlugin from "dayjs/plugin/utc";
@@ -24,16 +24,17 @@ export async function fetchFlagChangeContext(
limit: number
) {
const { start, end } = getRangeBounds(range, appContext.timezone);
+ const websiteId = requireWebsiteId(appContext);
const scopeCondition = appContext.organizationId
? or(
- eq(flagChangeEvents.websiteId, appContext.websiteId),
+ eq(flagChangeEvents.websiteId, websiteId),
and(
eq(flagChangeEvents.organizationId, appContext.organizationId),
isNull(flagChangeEvents.websiteId)
)
)
- : eq(flagChangeEvents.websiteId, appContext.websiteId);
+ : eq(flagChangeEvents.websiteId, websiteId);
const rows = await db
.select({
diff --git a/packages/ai/src/ai/insights/ops-context.ts b/packages/ai/src/ai/insights/ops-context.ts
index fa7cbe1d2..c1a01ee95 100644
--- a/packages/ai/src/ai/insights/ops-context.ts
+++ b/packages/ai/src/ai/insights/ops-context.ts
@@ -1,4 +1,4 @@
-import type { AppContext } from "../config/context";
+import { type AppContext, requireWebsiteId } from "../config/context";
import { callRPCProcedure } from "../tools/utils";
import { executeQuery } from "../../query";
import type { QueryRequest } from "../../query/types";
@@ -30,7 +30,7 @@ function runQuery(
) {
return executeQuery(
{
- projectId: appContext.websiteId,
+ projectId: requireWebsiteId(appContext),
type,
from: range.from,
to: range.to,
diff --git a/packages/ai/src/ai/insights/product-context.ts b/packages/ai/src/ai/insights/product-context.ts
index 8a2f5bbd2..67c60f3d1 100644
--- a/packages/ai/src/ai/insights/product-context.ts
+++ b/packages/ai/src/ai/insights/product-context.ts
@@ -1,4 +1,4 @@
-import type { AppContext } from "../config/context";
+import { type AppContext, requireWebsiteId } from "../config/context";
import { callRPCProcedure } from "../tools/utils";
import { executeQuery } from "../../query";
import type { QueryRequest } from "../../query/types";
@@ -146,7 +146,7 @@ function runQuery(
) {
return executeQuery(
{
- projectId: appContext.websiteId,
+ projectId: requireWebsiteId(appContext),
type,
from: range.from,
to: range.to,
diff --git a/packages/ai/src/ai/prompts/analytics.ts b/packages/ai/src/ai/prompts/analytics.ts
index a44ae6779..a4702dc2c 100644
--- a/packages/ai/src/ai/prompts/analytics.ts
+++ b/packages/ai/src/ai/prompts/analytics.ts
@@ -181,13 +181,42 @@ Slack rules:
Examples: "which first?" with thread metrics => read thread and pick one. "nah that's wrong" => ask for correction.
`;
+function buildWebsiteScopeGuidance(ctx: AppContext): string {
+ const websites = ctx.accessibleWebsites ?? [];
+ const defaultId = ctx.defaultWebsiteId ?? ctx.websiteId;
+
+ if (defaultId) {
+ const defaultDomain = ctx.websiteDomain ? ` (${ctx.websiteDomain})` : "";
+ return `A default website is selected for this chat: websiteId "${defaultId}"${defaultDomain}. Omit websiteId on tools to use it. The user can mention other websites; when they name or @-mention a different site, pass that website's id explicitly. Use list_websites if you need to look up an id.`;
+ }
+
+ const only = websites[0];
+ if (websites.length === 1 && only) {
+ return `This workspace has one website: websiteId "${only.id}"${only.domain ? ` (${only.domain})` : ""}. Use it for analytics tools; you do not need to call list_websites.`;
+ }
+
+ if (websites.length > 1) {
+ return "No single website is selected. The accessible websites are listed in . For analytics tools, pass the websiteId that matches the user's request; if the request is ambiguous about which site, ask which one. Use list_websites if you need the full list. To compare sites, query each with its own websiteId.";
+ }
+
+ return "No website is selected yet. Call list_websites first to discover available websites, then pass the chosen websiteId to analytics tools.";
+}
+
export function buildAnalyticsInstructions(ctx: AppContext): string {
- return `You are Databunny, an analytics assistant for ${ctx.websiteDomain}.
+ const intro = ctx.websiteDomain
+ ? `You are Databunny, an analytics assistant for ${ctx.websiteDomain}.`
+ : "You are Databunny, an analytics assistant for this workspace.";
+
+ return `${intro}
${formatContextForLLM(ctx)}
+
+${buildWebsiteScopeGuidance(ctx)}
+
+
${COMMON_AGENT_RULES}
${ANALYTICS_BODY}
diff --git a/packages/ai/src/ai/tools/dashboard-actions.ts b/packages/ai/src/ai/tools/dashboard-actions.ts
index 85b78a9d5..5c099fa14 100644
--- a/packages/ai/src/ai/tools/dashboard-actions.ts
+++ b/packages/ai/src/ai/tools/dashboard-actions.ts
@@ -84,7 +84,8 @@ export const dashboardActionsTool = tool({
}),
execute: ({ actions, title, websiteId }, options) => {
const context = getAppContext(options);
- const resolvedWebsiteId = websiteId ?? context.websiteId;
+ const resolvedWebsiteId =
+ websiteId ?? context.defaultWebsiteId ?? context.websiteId;
return {
type: "dashboard-actions",
diff --git a/packages/ai/src/ai/tools/execute-sql-query.ts b/packages/ai/src/ai/tools/execute-sql-query.ts
index 6e54ac9b7..aa514f154 100644
--- a/packages/ai/src/ai/tools/execute-sql-query.ts
+++ b/packages/ai/src/ai/tools/execute-sql-query.ts
@@ -6,7 +6,12 @@ import {
} from "@databuddy/db/clickhouse";
import { tool } from "ai";
import { z } from "zod";
-import { executeTimedQuery, getAppContext, type QueryResult } from "./utils";
+import {
+ executeTimedQuery,
+ getAppContext,
+ type QueryResult,
+ resolveToolWebsite,
+} from "./utils";
const MAX_MODEL_ROWS = 50;
@@ -90,6 +95,12 @@ Gotchas: timestamp column is "time" in events, "timestamp" elsewhere. Pageviews
.describe(
"Read-only ClickHouse SELECT/WITH query for an explicit analytics request. Must include client_id = {websiteId:String} AND-ed at the top level of every SELECT's WHERE."
),
+ websiteId: z
+ .string()
+ .optional()
+ .describe(
+ "Target website id. Omit to use the workspace default. Get ids from list_websites. The {websiteId:String} placeholder is bound to this site server-side."
+ ),
params: z
.record(z.string(), z.unknown())
.optional()
@@ -97,11 +108,12 @@ Gotchas: timestamp column is "time" in events, "timestamp" elsewhere. Pageviews
"Optional typed placeholder values. websiteId and websiteDomain are bound by the server and cannot be overridden."
),
}),
- execute: ({ sql, params }, options): Promise => {
+ execute: ({ sql, websiteId, params }, options): Promise => {
const ctx = getAppContext(options);
+ const resolved = resolveToolWebsite(ctx, websiteId);
return executeAgentSqlForWebsite({
- websiteId: ctx.websiteId,
- websiteDomain: ctx.websiteDomain,
+ websiteId: resolved.websiteId,
+ websiteDomain: resolved.domain,
sql,
params,
});
diff --git a/packages/ai/src/ai/tools/get-data.ts b/packages/ai/src/ai/tools/get-data.ts
index b63c7179b..59bdd1edd 100644
--- a/packages/ai/src/ai/tools/get-data.ts
+++ b/packages/ai/src/ai/tools/get-data.ts
@@ -3,10 +3,16 @@ import { z } from "zod";
import { getWebsiteDomain } from "../../lib/website-utils";
import { executeQuery, QueryBuilders } from "../../query";
import type { QueryRequest } from "../../query/types";
-import { getAppContext } from "./utils";
+import { getAppContext, resolveToolWebsite } from "./utils";
const queryItemSchema = z.object({
type: z.string(),
+ websiteId: z
+ .string()
+ .optional()
+ .describe(
+ "Target website id. Omit to use the workspace default. Required when comparing or querying a specific site in a multi-website workspace; get ids from list_websites."
+ ),
from: z.string().optional(),
to: z.string().optional(),
preset: z
@@ -50,6 +56,7 @@ interface QueryItemResult {
executionTime: number;
rowCount: number;
type: string;
+ websiteId?: string;
}
const MAX_MODEL_ROWS = 50;
@@ -108,7 +115,7 @@ const BUILDER_CATEGORIES = `Builder types by category:
- Revenue: revenue_overview, revenue_time_series, revenue_by_provider, revenue_by_product, revenue_attribution_overview, revenue_by_country, revenue_by_region, revenue_by_city, revenue_by_browser, revenue_by_device, revenue_by_os, revenue_by_referrer, revenue_by_utm_source, revenue_by_utm_medium, revenue_by_utm_campaign, revenue_by_entry_page, recent_transactions`;
export const getDataTool = tool({
- description: `Run analytics query builders only when the latest user message explicitly asks for website analytics data, metrics, reports, comparisons, breakdowns, trends, revenue, sessions, pages, events, errors, vitals, uptime, LLM usage, links, profiles, or similar quantitative analysis. Do not use for greetings, thanks, acknowledgments, short reactions, clarification-only replies, frustration, or meta-conversation about the assistant/chat. Batch 1-10 queries in parallel. Use preset (last_7d/last_30d/...) or from+to dates. The current website is bound server-side from the authorized chat session.\n\n${BUILDER_CATEGORIES}`,
+ description: `Run analytics query builders only when the latest user message explicitly asks for website analytics data, metrics, reports, comparisons, breakdowns, trends, revenue, sessions, pages, events, errors, vitals, uptime, LLM usage, links, profiles, or similar quantitative analysis. Do not use for greetings, thanks, acknowledgments, short reactions, clarification-only replies, frustration, or meta-conversation about the assistant/chat. Batch 1-10 queries in parallel. Use preset (last_7d/last_30d/...) or from+to dates. Each query may target a specific website via its websiteId; omit it to use the workspace default. To compare websites, send one query per website with different websiteId values.\n\n${BUILDER_CATEGORIES}`,
inputSchema: z.object({
queries: z
.array(queryItemSchema)
@@ -120,9 +127,7 @@ export const getDataTool = tool({
}),
execute: async ({ queries }, options) => {
const ctx = getAppContext(options);
- const websiteId = ctx.websiteId;
const batchStart = Date.now();
- const domain = ctx.websiteDomain || (await getWebsiteDomain(websiteId));
const results = await Promise.all(
queries.map(async (item): Promise => {
@@ -138,6 +143,25 @@ export const getDataTool = tool({
};
}
+ let websiteId: string;
+ let resolvedDomain: string | undefined;
+ try {
+ const resolved = resolveToolWebsite(ctx, item.websiteId);
+ websiteId = resolved.websiteId;
+ resolvedDomain = resolved.domain;
+ } catch (error) {
+ return {
+ type: item.type,
+ websiteId: item.websiteId,
+ data: [],
+ rowCount: 0,
+ executionTime: 0,
+ error:
+ error instanceof Error ? error.message : "Website not resolved",
+ };
+ }
+
+ const domain = resolvedDomain || (await getWebsiteDomain(websiteId));
const { from, to } = resolveDates(item);
const req: QueryRequest = {
projectId: websiteId,
@@ -155,6 +179,7 @@ export const getDataTool = tool({
const data = await executeQuery(req, domain, req.timezone);
return {
type: item.type,
+ websiteId,
data: data.slice(0, MAX_MODEL_ROWS),
rowCount: data.length,
executionTime: Date.now() - queryStart,
@@ -164,7 +189,9 @@ export const getDataTool = tool({
const resultMap: Record = {};
for (const r of results) {
- resultMap[r.type] = r;
+ const key =
+ r.websiteId && resultMap[r.type] ? `${r.type}@${r.websiteId}` : r.type;
+ resultMap[key] = r;
}
return {
diff --git a/packages/ai/src/ai/tools/investigation-tools.ts b/packages/ai/src/ai/tools/investigation-tools.ts
index dbe74b727..e116729aa 100644
--- a/packages/ai/src/ai/tools/investigation-tools.ts
+++ b/packages/ai/src/ai/tools/investigation-tools.ts
@@ -4,7 +4,7 @@ import { createScrapeTools } from "./scrape-page";
import { createSearchConsoleTools } from "./search-console";
export interface InvestigationToolsParams {
- domain: string;
+ domain?: string;
organizationId: string;
userId?: string;
}
@@ -13,7 +13,7 @@ export function createInvestigationTools(
params: InvestigationToolsParams
): ToolSet {
return {
- ...createScrapeTools(params.domain),
+ ...createScrapeTools(),
...createSearchConsoleTools({
domain: params.domain,
organizationId: params.organizationId,
diff --git a/packages/ai/src/ai/tools/list-websites.ts b/packages/ai/src/ai/tools/list-websites.ts
new file mode 100644
index 000000000..6bd43388f
--- /dev/null
+++ b/packages/ai/src/ai/tools/list-websites.ts
@@ -0,0 +1,22 @@
+import { tool } from "ai";
+import { z } from "zod";
+import { getAppContext } from "./utils";
+
+export const listWebsitesTool = tool({
+ description:
+ "List the websites in this workspace that you can query. Returns each website's id, name, and domain. Use a returned id as the websiteId for analytics tools when the workspace has more than one website or when the user names a specific site.",
+ inputSchema: z.object({}),
+ execute: (_args, options) => {
+ const ctx = getAppContext(options);
+ const websites = ctx.accessibleWebsites ?? [];
+ return {
+ websites: websites.map((w) => ({
+ id: w.id,
+ name: w.name,
+ domain: w.domain,
+ })),
+ defaultWebsiteId: ctx.defaultWebsiteId ?? ctx.websiteId ?? null,
+ count: websites.length,
+ };
+ },
+});
diff --git a/packages/ai/src/ai/tools/profiles.ts b/packages/ai/src/ai/tools/profiles.ts
index 5faa578a2..2d0447fc4 100644
--- a/packages/ai/src/ai/tools/profiles.ts
+++ b/packages/ai/src/ai/tools/profiles.ts
@@ -3,9 +3,16 @@ import { z } from "zod";
import { getWebsiteDomain } from "../../lib/website-utils";
import { executeQuery } from "../../query";
import type { QueryRequest } from "../../query/types";
-import { getAppContext } from "./utils";
+import { getAppContext, resolveToolWebsite } from "./utils";
import { createToolLogger } from "./utils/logger";
+const websiteIdInput = z
+ .string()
+ .optional()
+ .describe(
+ "Target website id. Omit to use the workspace default. Get ids from list_websites."
+ );
+
const logger = createToolLogger("Profiles");
function daysAgo(d: number): string {
@@ -21,8 +28,9 @@ function today(): string {
export function createProfileTools() {
const listProfilesTool = tool({
description:
- "List recent visitor profiles (sessions, pageviews, device, geo, browser, referrer). Use for visitors/users/audience questions. The current website is bound server-side from the authorized chat session.",
+ "List recent visitor profiles (sessions, pageviews, device, geo, browser, referrer). Use for visitors/users/audience questions. Pass websiteId to target a specific site; omit to use the workspace default.",
inputSchema: z.object({
+ websiteId: websiteIdInput,
days: z.number().min(1).max(90).default(7),
limit: z.number().min(1).max(50).default(10),
filters: z
@@ -43,11 +51,15 @@ export function createProfileTools() {
)
.optional(),
}),
- execute: async ({ days, limit, filters }, options) => {
+ execute: async (
+ { websiteId: inputWebsiteId, days, limit, filters },
+ options
+ ) => {
const ctx = getAppContext(options);
- const websiteId = ctx.websiteId;
+ const resolved = resolveToolWebsite(ctx, inputWebsiteId);
+ const websiteId = resolved.websiteId;
try {
- const domain = ctx.websiteDomain || (await getWebsiteDomain(websiteId));
+ const domain = resolved.domain || (await getWebsiteDomain(websiteId));
const from = daysAgo(days);
const to = today();
@@ -88,16 +100,21 @@ export function createProfileTools() {
const getProfileTool = tool({
description:
- "Visitor detail by anonymous_id: first/last activity, sessions across analytics/custom/error/vital/link events, pageviews, duration, device, browser, OS, location. The current website is bound server-side.",
+ "Visitor detail by anonymous_id: first/last activity, sessions across analytics/custom/error/vital/link events, pageviews, duration, device, browser, OS, location. Pass websiteId to target a specific site; omit to use the workspace default.",
inputSchema: z.object({
+ websiteId: websiteIdInput,
visitorId: z.string(),
days: z.number().min(1).max(365).default(30),
}),
- execute: async ({ visitorId, days }, options) => {
+ execute: async (
+ { websiteId: inputWebsiteId, visitorId, days },
+ options
+ ) => {
const ctx = getAppContext(options);
- const websiteId = ctx.websiteId;
+ const resolved = resolveToolWebsite(ctx, inputWebsiteId);
+ const websiteId = resolved.websiteId;
try {
- const domain = ctx.websiteDomain || (await getWebsiteDomain(websiteId));
+ const domain = resolved.domain || (await getWebsiteDomain(websiteId));
const from = daysAgo(days);
const to = today();
@@ -140,17 +157,22 @@ export function createProfileTools() {
const getProfileSessionsTool = tool({
description:
- "Session history for a visitor, including analytics events, custom events, errors, outgoing links, and separate web vitals context. Use after list_profiles/get_profile. The current website is bound server-side.",
+ "Session history for a visitor, including analytics events, custom events, errors, outgoing links, and separate web vitals context. Use after list_profiles/get_profile. Pass websiteId to target a specific site; omit to use the workspace default.",
inputSchema: z.object({
+ websiteId: websiteIdInput,
visitorId: z.string(),
days: z.number().min(1).max(365).default(30),
limit: z.number().min(1).max(100).default(20),
}),
- execute: async ({ visitorId, days, limit }, options) => {
+ execute: async (
+ { websiteId: inputWebsiteId, visitorId, days, limit },
+ options
+ ) => {
const ctx = getAppContext(options);
- const websiteId = ctx.websiteId;
+ const resolved = resolveToolWebsite(ctx, inputWebsiteId);
+ const websiteId = resolved.websiteId;
try {
- const domain = ctx.websiteDomain || (await getWebsiteDomain(websiteId));
+ const domain = resolved.domain || (await getWebsiteDomain(websiteId));
const from = daysAgo(days);
const to = today();
diff --git a/packages/ai/src/ai/tools/scrape-page.ts b/packages/ai/src/ai/tools/scrape-page.ts
index 29f916241..0fb4d6902 100644
--- a/packages/ai/src/ai/tools/scrape-page.ts
+++ b/packages/ai/src/ai/tools/scrape-page.ts
@@ -1,5 +1,7 @@
import { tool } from "ai";
import { z } from "zod";
+import { getWebsiteDomain } from "../../lib/website-utils";
+import { getAppContext, resolveToolWebsite } from "./utils";
const FIRECRAWL_API = "https://api.firecrawl.dev/v1";
const MAX_CONTENT_CHARS = 12_000;
@@ -202,17 +204,33 @@ export async function getCachedSiteContext(
}
}
-export function createScrapeTools(domain: string) {
+export function createScrapeTools() {
const scrapeTool = tool({
- description: `Scrape a page from ${domain} and return its content as markdown plus internal links. Use to understand the product: what the site does, key pages, pricing, CTAs. Also use when investigating page-level anomalies. Scrape "/" first for product context, then specific pages as needed. Results are cached for 24h.`,
+ description:
+ 'Scrape a page from one of the workspace websites and return its content as markdown plus internal links. Use to understand the product: what the site does, key pages, pricing, CTAs. Also use when investigating page-level anomalies. Scrape "/" first for product context, then specific pages as needed. Pass websiteId to target a specific site; omit to use the workspace default. Results are cached for 24h.',
inputSchema: z.object({
+ websiteId: z
+ .string()
+ .optional()
+ .describe(
+ "Target website id. Omit to use the workspace default. Get ids from list_websites."
+ ),
path: z
.string()
.describe(
- "Page path to scrape (e.g. '/', '/pricing', '/docs'). Must be a path on the current website."
+ "Page path to scrape (e.g. '/', '/pricing', '/docs'). Must be a path on the target website."
),
}),
- execute: ({ path }) => scrapePage(domain, path),
+ execute: async ({ websiteId, path }, options) => {
+ const ctx = getAppContext(options);
+ const resolved = resolveToolWebsite(ctx, websiteId);
+ const domain =
+ resolved.domain || (await getWebsiteDomain(resolved.websiteId));
+ if (!domain) {
+ return { error: "Could not resolve a domain for the target website" };
+ }
+ return scrapePage(domain, path);
+ },
});
return { scrape_page: scrapeTool };
diff --git a/packages/ai/src/ai/tools/search-console.ts b/packages/ai/src/ai/tools/search-console.ts
index 9398e9c0e..2a4de6b71 100644
--- a/packages/ai/src/ai/tools/search-console.ts
+++ b/packages/ai/src/ai/tools/search-console.ts
@@ -1,5 +1,7 @@
import { tool } from "ai";
import { z } from "zod";
+import { getWebsiteDomain } from "../../lib/website-utils";
+import { getAppContext, resolveToolWebsite } from "./utils";
import { createCachedTokenFn } from "./utils/oauth-token";
const GSC_API = "https://www.googleapis.com/webmasters/v3";
@@ -8,6 +10,12 @@ const MAX_ROWS = 25;
const dimensionEnum = z.enum(["query", "page", "country", "device", "date"]);
const searchAnalyticsInput = z.object({
+ websiteId: z
+ .string()
+ .optional()
+ .describe(
+ "Target website id. Omit to use the workspace default. Get ids from list_websites."
+ ),
startDate: z.string().describe("Start date YYYY-MM-DD"),
endDate: z.string().describe("End date YYYY-MM-DD"),
dimensions: dimensionEnum
@@ -86,7 +94,7 @@ export async function querySearchAnalytics(
}
export function createSearchConsoleTools(params: {
- domain: string;
+ domain?: string;
organizationId: string;
userId?: string;
}) {
@@ -95,13 +103,29 @@ export function createSearchConsoleTools(params: {
params.organizationId,
params.userId
);
- const siteUrl = `sc-domain:${params.domain}`;
return {
search_console: tool({
- description: `Query Google Search Console for ${params.domain}. Returns search queries, pages, countries, or devices with clicks, impressions, CTR, and average position. Use to find which keywords lost rankings, which pages dropped in impressions, or where traffic is coming from in Google search.`,
+ description:
+ "Query Google Search Console for a workspace website. Returns search queries, pages, countries, or devices with clicks, impressions, CTR, and average position. Use to find which keywords lost rankings, which pages dropped in impressions, or where traffic is coming from in Google search. Pass websiteId to target a specific site; omit to use the workspace default.",
inputSchema: searchAnalyticsInput,
- execute: async (input) => {
+ execute: async (input, options) => {
+ const ctx = getAppContext(options);
+ let domain = params.domain;
+ if (!domain) {
+ const resolved = resolveToolWebsite(ctx, input.websiteId);
+ domain =
+ resolved.domain ||
+ (await getWebsiteDomain(resolved.websiteId)) ||
+ undefined;
+ }
+ if (!domain) {
+ return {
+ error: "Could not resolve a domain for the target website",
+ };
+ }
+ const siteUrl = `sc-domain:${domain}`;
+
const token = await getToken();
if (!token) {
return {
diff --git a/packages/ai/src/ai/tools/toolkit.ts b/packages/ai/src/ai/tools/toolkit.ts
index bf9854a42..5c3bfa1c4 100644
--- a/packages/ai/src/ai/tools/toolkit.ts
+++ b/packages/ai/src/ai/tools/toolkit.ts
@@ -7,6 +7,7 @@ import { getDataTool } from "./get-data";
import { createGoalTools } from "./goals";
import { createInvestigationTools } from "./investigation-tools";
import { createLinksTools } from "./links";
+import { listWebsitesTool } from "./list-websites";
import { createMemoryTools } from "./memory";
import { createProfileTools } from "./profiles";
import { dashboardActionsTool } from "./dashboard-actions";
@@ -26,6 +27,7 @@ export interface ToolkitParams {
}
const ANALYTICS_TOOLS: ToolSet = {
+ list_websites: listWebsitesTool,
get_data: getDataTool,
execute_sql_query: executeSqlQueryTool,
};
@@ -55,7 +57,7 @@ export function createToolkit(params: ToolkitParams): ToolSet {
Object.assign(tools, ANALYTICS_TOOLS);
}
- if (caps.has("investigation") && params.domain && params.organizationId) {
+ if (caps.has("investigation") && params.organizationId) {
Object.assign(
tools,
createInvestigationTools({
diff --git a/packages/ai/src/ai/tools/utils/context.ts b/packages/ai/src/ai/tools/utils/context.ts
index 37e629cb7..065ed3ccd 100644
--- a/packages/ai/src/ai/tools/utils/context.ts
+++ b/packages/ai/src/ai/tools/utils/context.ts
@@ -11,3 +11,44 @@ export function getAppContext(options: {
}
return ctx as AppContext;
}
+
+export interface ResolvedWebsite {
+ domain?: string;
+ websiteId: string;
+}
+
+export function resolveToolWebsite(
+ ctx: AppContext,
+ inputWebsiteId?: string | null
+): ResolvedWebsite {
+ const accessible = ctx.accessibleWebsites ?? [];
+ const domainFor = (id: string): string | undefined =>
+ accessible.find((w) => w.id === id)?.domain ??
+ (id === ctx.websiteId ? ctx.websiteDomain : undefined);
+
+ if (inputWebsiteId) {
+ if (
+ accessible.length > 0 &&
+ !accessible.some((w) => w.id === inputWebsiteId)
+ ) {
+ throw new Error(
+ `Website "${inputWebsiteId}" is not in this workspace. Call list_websites to see available websites.`
+ );
+ }
+ return { websiteId: inputWebsiteId, domain: domainFor(inputWebsiteId) };
+ }
+
+ const fallbackId = ctx.defaultWebsiteId ?? ctx.websiteId;
+ if (fallbackId) {
+ return { websiteId: fallbackId, domain: domainFor(fallbackId) };
+ }
+
+ const [only] = accessible;
+ if (accessible.length === 1 && only) {
+ return { websiteId: only.id, domain: only.domain ?? undefined };
+ }
+
+ throw new Error(
+ "No website specified. This workspace has multiple websites — pass a websiteId for this query. Call list_websites to see the options."
+ );
+}
diff --git a/packages/ai/src/ai/tools/utils/index.ts b/packages/ai/src/ai/tools/utils/index.ts
index d278ca9d4..990b70a17 100644
--- a/packages/ai/src/ai/tools/utils/index.ts
+++ b/packages/ai/src/ai/tools/utils/index.ts
@@ -1,5 +1,9 @@
/** biome-ignore-all lint/performance/noBarrelFile: no barrel file */
-export { getAppContext } from "./context";
+export {
+ getAppContext,
+ resolveToolWebsite,
+ type ResolvedWebsite,
+} from "./context";
export { createToolLogger } from "./logger";
export { getOAuthToken, createCachedTokenFn } from "./oauth-token";
export { executeTimedQuery, type QueryResult } from "./query";
From 4477437080d5fe2208a376846b0392847e832490 Mon Sep 17 00:00:00 2001
From: iza <59828082+izadoesdev@users.noreply.github.com>
Date: Sat, 30 May 2026 21:06:03 +0300
Subject: [PATCH 21/34] feat(api): serve the agent over an organization
workspace
Resolve the agent's accessible websites and default website from the
organization context so a single chat can operate across the workspace's
sites instead of one fixed website.
---
apps/api/src/routes/agent.ts | 259 +++++++++++++++++++++--------------
1 file changed, 159 insertions(+), 100 deletions(-)
diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts
index 5e6082305..eccde96c0 100644
--- a/apps/api/src/routes/agent.ts
+++ b/apps/api/src/routes/agent.ts
@@ -1,7 +1,5 @@
import {
- getAccessibleWebsiteIds,
getApiKeyFromHeader,
- hasGlobalAccess,
hasKeyScope,
isApiKeyPresent,
} from "@databuddy/api-keys/resolve";
@@ -60,7 +58,7 @@ import { Elysia, t } from "elysia";
import { log, parseError } from "evlog";
import { useLogger } from "evlog/elysia";
import {
- checkWebsiteReadPermissionCached,
+ type AgentContextSnapshotResult,
getAgentContextSnapshot,
getMemoryContextCached,
shouldLoadMemoryContext,
@@ -69,7 +67,7 @@ import { getAILogger } from "../lib/ai-logger";
import { trackAgentEvent } from "../lib/databuddy";
import { getResolvedAuth } from "../lib/auth-wide-event";
import { captureError, mergeWideEvent } from "../lib/tracing";
-import { validateWebsite } from "../lib/website-utils";
+import { getAccessibleWebsites } from "../lib/accessible-websites";
function jsonError(status: number, code: string, message: string): Response {
return new Response(
@@ -228,8 +226,12 @@ const UIMessageSchema = t.Object({
),
});
+const MAX_MENTIONS = 20;
+
const AgentRequestSchema = t.Object({
- websiteId: t.String(),
+ organizationId: t.Optional(t.String()),
+ websiteId: t.Optional(t.String()),
+ mentions: t.Optional(t.Array(t.String(), { maxItems: MAX_MENTIONS })),
messages: t.Array(UIMessageSchema, { maxItems: MAX_MESSAGES }),
id: t.Optional(t.String()),
timezone: t.Optional(t.String()),
@@ -475,25 +477,34 @@ function createPlainTextStreamResponse(
export const agent = new Elysia({ prefix: "/v1/agent" })
.derive(async ({ request }) => {
const preResolved = getResolvedAuth(request.headers);
- let user = preResolved?.session?.user ?? null;
+ let session = preResolved?.session ?? null;
let apiKey = preResolved?.apiKeyResult?.key ?? null;
if (!preResolved) {
const hasApiKey = isApiKeyPresent(request.headers);
- const [resolvedApiKey, session] = await Promise.all([
+ const [resolvedApiKey, freshSession] = await Promise.all([
hasApiKey ? getApiKeyFromHeader(request.headers) : null,
auth.api.getSession({ headers: request.headers }),
]);
- user = session?.user ?? null;
+ session = freshSession;
apiKey = resolvedApiKey;
}
+ const user = session?.user ?? null;
+ const activeOrganizationId =
+ (
+ session?.session as
+ | { activeOrganizationId?: string | null }
+ | undefined
+ )?.activeOrganizationId ?? null;
+
const validApiKey =
apiKey && hasKeyScope(apiKey, "read:data") ? apiKey : null;
return {
user,
apiKey: validApiKey,
+ activeOrganizationId,
isAuthenticated: Boolean(user ?? validApiKey),
};
})
@@ -591,14 +602,13 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
)
.post(
"/chat",
- function agentChat({ body, user, apiKey, request }) {
+ function agentChat({ body, user, apiKey, activeOrganizationId, request }) {
return (async () => {
const chatId = body.id ?? generateId();
const t0 = performance.now();
let organizationId: string | null = null;
mergeWideEvent({
- agent_website_id: body.websiteId,
agent_user_id: user?.id ?? "unknown",
agent_chat_id: chatId,
});
@@ -609,8 +619,27 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
}
const userId = user?.id ?? `apikey:${apiKey?.id}`;
+ organizationId =
+ body.organizationId ??
+ activeOrganizationId ??
+ apiKey?.organizationId ??
+ null;
+
+ if (!organizationId) {
+ return jsonError(
+ 400,
+ "WORKSPACE_REQUIRED",
+ "No active workspace. Select an organization and try again."
+ );
+ }
+
+ mergeWideEvent({
+ organization_id: organizationId,
+ ...(body.websiteId ? { agent_website_id: body.websiteId } : {}),
+ });
+
const rl = await ratelimit(
- `agent:chat:${userId}:${body.websiteId}`,
+ `agent:chat:${userId}:${organizationId}`,
30,
60
);
@@ -622,50 +651,15 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
);
}
- const websiteValidation = await timeAgentPhase(
- "validate_website",
- validateWebsite(body.websiteId)
- );
-
- if (!(websiteValidation.success && websiteValidation.website)) {
- return jsonError(
- 404,
- "WEBSITE_NOT_FOUND",
- websiteValidation.error ?? "Website not found"
- );
- }
-
- const { website } = websiteValidation;
- organizationId = website.organizationId ?? null;
-
- const resolvePermission = (): Promise => {
- if (apiKey) {
- if (hasGlobalAccess(apiKey)) {
- return Promise.resolve(
- apiKey.organizationId != null &&
- apiKey.organizationId === website.organizationId
- );
- }
- return Promise.resolve(
- getAccessibleWebsiteIds(apiKey).includes(body.websiteId)
- );
- }
- if (!(user && website.organizationId)) {
- return Promise.resolve(false);
- }
- return checkWebsiteReadPermissionCached(
- user.id,
- website.organizationId,
- request.headers
- );
- };
- const permissionCheck = timeAgentPhase(
- "permission_check",
- resolvePermission()
- );
-
- const [hasPermission, billingCustomerId] = await Promise.all([
- permissionCheck,
+ const [accessibleWebsites, billingCustomerId] = await Promise.all([
+ timeAgentPhase(
+ "accessible_websites",
+ getAccessibleWebsites({
+ user: user ? { id: user.id } : null,
+ apiKey,
+ organizationId,
+ })
+ ),
timeAgentPhase(
"resolve_billing",
resolveAgentBillingCustomerId({
@@ -676,30 +670,53 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
),
]);
- if (!hasPermission) {
+ if (accessibleWebsites.length === 0) {
return jsonError(
403,
"ACCESS_DENIED",
- "Access denied to this website"
+ "No accessible websites in this workspace"
);
}
+ let defaultWebsiteId: string | null = null;
+ let defaultDomain: string | undefined;
+ if (body.websiteId) {
+ const match = accessibleWebsites.find(
+ (w) => w.id === body.websiteId
+ );
+ if (!match) {
+ return jsonError(
+ 403,
+ "ACCESS_DENIED",
+ "Access denied to this website"
+ );
+ }
+ defaultWebsiteId = match.id;
+ defaultDomain = match.domain ?? undefined;
+ }
+
+ const mentionedWebsites = (body.mentions ?? [])
+ .map((id) => accessibleWebsites.find((w) => w.id === id))
+ .filter((w): w is (typeof accessibleWebsites)[number] =>
+ Boolean(w)
+ );
+
if (body.id) {
const existingChat = await db.query.agentChats.findFirst({
where: { id: chatId },
- columns: { userId: true, websiteId: true },
+ columns: { userId: true, organizationId: true },
});
if (
existingChat &&
(existingChat.userId !== userId ||
- existingChat.websiteId !== body.websiteId)
+ (existingChat.organizationId != null &&
+ existingChat.organizationId !== organizationId))
) {
return jsonError(403, "ACCESS_DENIED", "Access denied to chat");
}
}
const timezone = body.timezone ?? "UTC";
- const domain = website.domain ?? "unknown";
const lastMessage = getLastMessagePreview(body.messages);
const agentTier: AgentTier = body.tier ?? "balanced";
@@ -714,7 +731,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
action: "chat_started",
source: "dashboard",
agent_type: AGENT_TYPE,
- website_id: body.websiteId,
+ website_id: defaultWebsiteId,
organization_id: organizationId,
user_id: userId,
});
@@ -722,7 +739,8 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
useLogger().info("Creating agent", {
agent: {
type: AGENT_TYPE,
- websiteId: body.websiteId,
+ websiteId: defaultWebsiteId,
+ accessibleWebsiteCount: accessibleWebsites.length,
messageCount: body.messages.length,
lastMessage,
},
@@ -735,7 +753,9 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
captureError(err, {
agent_credit_check_error: true,
agent_chat_id: chatId,
- agent_website_id: body.websiteId,
+ ...(defaultWebsiteId
+ ? { agent_website_id: defaultWebsiteId }
+ : {}),
});
return true;
})
@@ -759,28 +779,41 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
"memory_enrich",
Promise.all([
creditsCheck,
- loadMemoryContext
+ loadMemoryContext && defaultWebsiteId
? optionalAgentContext(
"memory",
- getMemoryContextCached(lastMessage, userId, body.websiteId),
+ getMemoryContextCached(
+ lastMessage,
+ userId,
+ defaultWebsiteId
+ ),
EMPTY_MEMORY_CONTEXT,
AGENT_MEMORY_CONTEXT_TIMEOUT_MS,
{
agent_chat_id: chatId,
- agent_website_id: body.websiteId,
+ agent_website_id: defaultWebsiteId,
}
)
: Promise.resolve(EMPTY_MEMORY_CONTEXT),
- optionalAgentContext(
- "enrichment",
- getAgentContextSnapshot(userId, body.websiteId, organizationId),
- { context: "", source: "error" },
- AGENT_ENRICHMENT_CONTEXT_TIMEOUT_MS,
- {
- agent_chat_id: chatId,
- agent_website_id: body.websiteId,
- }
- ),
+ defaultWebsiteId
+ ? optionalAgentContext(
+ "enrichment",
+ getAgentContextSnapshot(
+ userId,
+ defaultWebsiteId,
+ organizationId
+ ),
+ { context: "", source: "error" },
+ AGENT_ENRICHMENT_CONTEXT_TIMEOUT_MS,
+ {
+ agent_chat_id: chatId,
+ agent_website_id: defaultWebsiteId,
+ }
+ )
+ : Promise.resolve({
+ context: "",
+ source: "miss",
+ }),
])
);
mergeWideEvent({
@@ -805,8 +838,10 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
{
userId,
organizationId: organizationId ?? undefined,
- websiteId: body.websiteId,
- websiteDomain: domain,
+ websiteId: defaultWebsiteId ?? undefined,
+ websiteDomain: defaultDomain,
+ defaultWebsiteId,
+ accessibleWebsites,
timezone,
chatId,
requestHeaders: request.headers,
@@ -817,9 +852,20 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
modelOverride
);
+ const mentionContext =
+ mentionedWebsites.length > 0
+ ? `\nThe user referenced these websites in their message. Prioritize them when choosing which website(s) to query:\n${mentionedWebsites
+ .map(
+ (w) =>
+ `- ${w.name ?? w.domain ?? w.id} (id: ${w.id}${w.domain ? `, domain: ${w.domain}` : ""})`
+ )
+ .join("\n")}\n`
+ : "";
+
const extras = [
memoryCtx ? formatMemoryForPrompt(memoryCtx) : "",
enrichment.context,
+ mentionContext,
]
.filter(Boolean)
.join("\n\n");
@@ -863,14 +909,18 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
const dashboardTelemetryMetadata: Record = {
source: "dashboard",
userId,
- websiteId: body.websiteId,
- websiteDomain: domain,
chatId,
agentType: AGENT_TYPE,
timezone,
"tcc.sessionId": chatId,
"tcc.conversational": "true",
};
+ if (defaultWebsiteId) {
+ dashboardTelemetryMetadata.websiteId = defaultWebsiteId;
+ }
+ if (defaultDomain) {
+ dashboardTelemetryMetadata.websiteDomain = defaultDomain;
+ }
if (organizationId) {
dashboardTelemetryMetadata.organizationId = organizationId;
}
@@ -881,7 +931,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
metadata: dashboardTelemetryMetadata,
});
- if (isMemoryEnabled() && lastMessage) {
+ if (isMemoryEnabled() && lastMessage && defaultWebsiteId) {
storeConversation(
[{ role: "user", content: lastMessage }],
userId,
@@ -890,9 +940,9 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
metadata: {
source: "dashboard",
},
- websiteId: body.websiteId,
+ websiteId: defaultWebsiteId,
conversationId: chatId,
- domain,
+ domain: defaultDomain ?? "unknown",
}
);
}
@@ -941,7 +991,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
modelId: modelNames[modelKey],
source: "dashboard",
agentType: AGENT_TYPE,
- websiteId: body.websiteId,
+ websiteId: defaultWebsiteId ?? undefined,
organizationId,
userId: persistedUserId ?? null,
chatId,
@@ -952,14 +1002,17 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
captureError(usageError, {
agent_usage_telemetry_error: true,
agent_chat_id: chatId,
- agent_website_id: body.websiteId,
+ ...(defaultWebsiteId
+ ? { agent_website_id: defaultWebsiteId }
+ : {}),
});
});
+ const streamScope = userId;
const streamId = generateId();
- const streamKey = streamBufferKey(body.websiteId, chatId, streamId);
+ const streamKey = streamBufferKey(streamScope, chatId, streamId);
await timeAgentPhase("stream_setup", () =>
- setActiveStream(body.websiteId, chatId, streamId)
+ setActiveStream(streamScope, chatId, streamId)
);
if (persistedUserId) {
@@ -969,7 +1022,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
.insert(agentChats)
.values({
id: chatId,
- websiteId: body.websiteId,
+ websiteId: defaultWebsiteId,
userId: persistedUserId,
organizationId: persistedOrgId,
title: fallbackTitle,
@@ -988,7 +1041,9 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
captureError(persistError, {
agent_user_message_persist_error: true,
agent_chat_id: chatId,
- agent_website_id: body.websiteId,
+ ...(defaultWebsiteId
+ ? { agent_website_id: defaultWebsiteId }
+ : {}),
});
}
}
@@ -998,7 +1053,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
originalMessages: validation.data,
onFinish: async ({ messages }) => {
try {
- await clearActiveStream(body.websiteId, chatId, streamId);
+ await clearActiveStream(streamScope, chatId, streamId);
} catch {}
if (!persistedUserId) {
return;
@@ -1008,7 +1063,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
.insert(agentChats)
.values({
id: chatId,
- websiteId: body.websiteId,
+ websiteId: defaultWebsiteId,
userId: persistedUserId,
organizationId: persistedOrgId,
title: fallbackTitle,
@@ -1036,7 +1091,9 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
captureError(persistError, {
agent_persist_error: true,
agent_chat_id: chatId,
- agent_website_id: body.websiteId,
+ ...(defaultWebsiteId
+ ? { agent_website_id: defaultWebsiteId }
+ : {}),
});
}
},
@@ -1069,7 +1126,9 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
captureError(storageError, {
agent_stream_persist_error: true,
agent_chat_id: chatId,
- agent_website_id: body.websiteId,
+ ...(defaultWebsiteId
+ ? { agent_website_id: defaultWebsiteId }
+ : {}),
});
});
return new Response(forClient, {
@@ -1088,7 +1147,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
agentType: AGENT_TYPE,
phase: "dashboard_chat_stream",
userId: user?.id ?? null,
- websiteId: body.websiteId,
+ websiteId: body.websiteId ?? null,
},
...(parsed.fix !== "" && parsed.fix != null
? { fix: parsed.fix }
@@ -1104,7 +1163,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
error_message: err.message,
error_name: err.name,
service: "api",
- websiteId: body.websiteId,
+ websiteId: body.websiteId ?? null,
...(parsed.fix !== "" && parsed.fix != null
? { fix: parsed.fix }
: {}),
@@ -1121,13 +1180,13 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
error_type: getErrorName(error),
organization_id: organizationId,
user_id: user?.id ?? null,
- website_id: body.websiteId,
+ website_id: body.websiteId ?? null,
});
captureError(error, {
agent_error: true,
agent_type: AGENT_TYPE,
agent_chat_id: chatId,
- agent_website_id: body.websiteId,
+ ...(body.websiteId ? { agent_website_id: body.websiteId } : {}),
agent_user_id: user?.id ?? "unknown",
error_type: getErrorName(error),
});
@@ -1143,16 +1202,16 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
}
const chat = await db.query.agentChats.findFirst({
where: { id: params.chatId, userId: user.id },
- columns: { id: true, websiteId: true },
+ columns: { id: true },
});
if (!chat) {
return new Response(null, { status: 204 });
}
- const streamId = await getActiveStream(chat.websiteId, chat.id);
+ const streamId = await getActiveStream(user.id, chat.id);
if (!streamId) {
return new Response(null, { status: 204 });
}
- const key = streamBufferKey(chat.websiteId, chat.id, streamId);
+ const key = streamBufferKey(user.id, chat.id, streamId);
const abortController = new AbortController();
request.signal?.addEventListener("abort", () => {
From bfdf951d3bc133fc310079fc22a7e800ba212401 Mon Sep 17 00:00:00 2001
From: iza <59828082+izadoesdev@users.noreply.github.com>
Date: Sat, 30 May 2026 21:06:16 +0300
Subject: [PATCH 22/34] feat(dashboard): rebuild the agent as an organization
workspace
Move the agent out from under a single website into a top-level workspace:
add a global /agent route with chat history, mentions for referencing
specific websites, and a no-websites empty state. Chats are now scoped to
the organization, so switching websites no longer loses the conversation.
---
.../app/(main)/agent/[chatId]/page.tsx | 11 +
.../agent/_components/agent-no-websites.tsx | 28 ++
.../agent/_components/global-agent-page.tsx | 40 +++
.../_components/global-agent-redirect.tsx | 29 ++
apps/dashboard/app/(main)/agent/layout.tsx | 6 +
apps/dashboard/app/(main)/agent/page.tsx | 5 +
apps/dashboard/app/(main)/layout.tsx | 27 +-
.../websites/[id]/agent/[chatId]/page.tsx | 7 +-
.../agent/_components/agent-page-client.tsx | 20 +-
.../agent/_components/agent-page-content.tsx | 96 ------
.../dashboard/components/agent/agent-atoms.ts | 8 +
.../components/agent/agent-chat-surface.tsx | 59 +++-
.../components/agent/agent-input.tsx | 294 +++++++++++++-----
.../components/agent/agent-mention-menu.tsx | 75 +++++
.../components/agent/agent-mentions.ts | 33 ++
.../components/agent/agent-workspace.tsx | 120 +++++++
.../components/agent/chat-history.tsx | 56 ++--
.../agent/global-agent-provider.tsx | 136 ++------
.../components/agent/hooks/use-agent-chat.ts | 21 +-
.../components/agent/hooks/use-chat-db.ts | 29 +-
.../components/agent/new-chat-button.tsx | 6 +-
.../layout/navigation/navigation-config.tsx | 4 +
apps/dashboard/contexts/chat-context.tsx | 16 +-
23 files changed, 743 insertions(+), 383 deletions(-)
create mode 100644 apps/dashboard/app/(main)/agent/[chatId]/page.tsx
create mode 100644 apps/dashboard/app/(main)/agent/_components/agent-no-websites.tsx
create mode 100644 apps/dashboard/app/(main)/agent/_components/global-agent-page.tsx
create mode 100644 apps/dashboard/app/(main)/agent/_components/global-agent-redirect.tsx
create mode 100644 apps/dashboard/app/(main)/agent/layout.tsx
create mode 100644 apps/dashboard/app/(main)/agent/page.tsx
delete mode 100644 apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-page-content.tsx
create mode 100644 apps/dashboard/components/agent/agent-mention-menu.tsx
create mode 100644 apps/dashboard/components/agent/agent-mentions.ts
create mode 100644 apps/dashboard/components/agent/agent-workspace.tsx
diff --git a/apps/dashboard/app/(main)/agent/[chatId]/page.tsx b/apps/dashboard/app/(main)/agent/[chatId]/page.tsx
new file mode 100644
index 000000000..56af147e8
--- /dev/null
+++ b/apps/dashboard/app/(main)/agent/[chatId]/page.tsx
@@ -0,0 +1,11 @@
+import { GlobalAgentPage } from "../_components/global-agent-page";
+
+interface Props {
+ params: Promise<{ chatId: string }>;
+}
+
+export default async function AgentChatPage(props: Props) {
+ const { chatId } = await props.params;
+
+ return ;
+}
diff --git a/apps/dashboard/app/(main)/agent/_components/agent-no-websites.tsx b/apps/dashboard/app/(main)/agent/_components/agent-no-websites.tsx
new file mode 100644
index 000000000..de5c57d95
--- /dev/null
+++ b/apps/dashboard/app/(main)/agent/_components/agent-no-websites.tsx
@@ -0,0 +1,28 @@
+import Link from "next/link";
+import { Button } from "@databuddy/ui";
+import { Avatar } from "@databuddy/ui/client";
+
+export function AgentNoWebsites() {
+ return (
+
+
+
+
+ Add a website to use Databunny
+
+
+ Databunny analyzes your analytics. Connect your first website to start
+ asking questions.
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/app/(main)/agent/_components/global-agent-page.tsx b/apps/dashboard/app/(main)/agent/_components/global-agent-page.tsx
new file mode 100644
index 000000000..ea35e0575
--- /dev/null
+++ b/apps/dashboard/app/(main)/agent/_components/global-agent-page.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { AgentWorkspace } from "@/components/agent/agent-workspace";
+import { useGlobalAgent } from "@/components/agent/global-agent-provider";
+import { ChatProvider } from "@/contexts/chat-context";
+import { Skeleton } from "@databuddy/ui";
+import { AgentNoWebsites } from "./agent-no-websites";
+
+export function GlobalAgentPage({ chatId }: { chatId: string }) {
+ const router = useRouter();
+ const { organizationId, hasWebsites, isLoading } = useGlobalAgent();
+
+ if (organizationId && !(isLoading || hasWebsites)) {
+ return ;
+ }
+
+ if (isLoading || !organizationId) {
+ return (
+