diff --git a/apps/dashboard/app/(dashboard)/studio/deploy/page.tsx b/apps/dashboard/app/(dashboard)/studio/deploy/page.tsx
new file mode 100644
index 0000000..266514b
--- /dev/null
+++ b/apps/dashboard/app/(dashboard)/studio/deploy/page.tsx
@@ -0,0 +1,21 @@
+import { notFound } from "next/navigation";
+import { PageHeader } from "../../../../components/page-header";
+import { DeployPanel } from "../../../../features/products/deploy-panel";
+import { getOrCreateProduct } from "../../../../features/products/queries";
+
+export const dynamic = "force-dynamic";
+
+export default async function StudioDeployPage() {
+ const product = await getOrCreateProduct();
+ if (!product) notFound();
+
+ return (
+
+ );
+}
diff --git a/apps/dashboard/app/(dashboard)/studio/inbox/page.tsx b/apps/dashboard/app/(dashboard)/studio/inbox/page.tsx
new file mode 100644
index 0000000..f5cd296
--- /dev/null
+++ b/apps/dashboard/app/(dashboard)/studio/inbox/page.tsx
@@ -0,0 +1,24 @@
+import { PageHeader } from "../../../../components/page-header";
+import { getOrCreateProduct } from "../../../../features/products/queries";
+
+export const dynamic = "force-dynamic";
+
+export default async function StudioInboxPage() {
+ // Ensure the single agent exists so nav into Inbox is consistent with other Studio pages.
+ await getOrCreateProduct();
+
+ return (
+
+
+
+
Conversations coming soon
+
+ Chats, actions taken, and live handoff will land here in a later task.
+
+
+
+ );
+}
diff --git a/apps/dashboard/app/(dashboard)/studio/page.tsx b/apps/dashboard/app/(dashboard)/studio/page.tsx
new file mode 100644
index 0000000..ec6e9e6
--- /dev/null
+++ b/apps/dashboard/app/(dashboard)/studio/page.tsx
@@ -0,0 +1,8 @@
+import { redirect } from "next/navigation";
+
+export const dynamic = "force-dynamic";
+
+/** /studio → Train (single-agent Studio). */
+export default function StudioIndexPage() {
+ redirect("/studio/train");
+}
diff --git a/apps/dashboard/app/(dashboard)/studio/settings/page.tsx b/apps/dashboard/app/(dashboard)/studio/settings/page.tsx
new file mode 100644
index 0000000..68d9c53
--- /dev/null
+++ b/apps/dashboard/app/(dashboard)/studio/settings/page.tsx
@@ -0,0 +1,32 @@
+import { notFound } from "next/navigation";
+import { PageHeader } from "../../../../components/page-header";
+import { ProductSettingsForm } from "../../../../features/products/product-settings-form";
+import { getOrCreateProduct } from "../../../../features/products/queries";
+
+export const dynamic = "force-dynamic";
+
+export default async function StudioSettingsPage() {
+ const product = await getOrCreateProduct();
+ if (!product) notFound();
+
+ return (
+
+
+ Live
+
+ ) : (
+
+ Draft
+
+ )
+ }
+ />
+
+
+ );
+}
diff --git a/apps/dashboard/app/(dashboard)/studio/test/page.tsx b/apps/dashboard/app/(dashboard)/studio/test/page.tsx
new file mode 100644
index 0000000..ab9050b
--- /dev/null
+++ b/apps/dashboard/app/(dashboard)/studio/test/page.tsx
@@ -0,0 +1,21 @@
+import { notFound } from "next/navigation";
+import { PageHeader } from "../../../../components/page-header";
+import { TestPreviewPanel } from "../../../../features/products/test-preview-panel";
+import { getOrCreateProduct } from "../../../../features/products/queries";
+
+export const dynamic = "force-dynamic";
+
+export default async function StudioTestPage() {
+ const product = await getOrCreateProduct();
+ if (!product) notFound();
+
+ return (
+
+ );
+}
diff --git a/apps/dashboard/app/(dashboard)/studio/train/page.tsx b/apps/dashboard/app/(dashboard)/studio/train/page.tsx
new file mode 100644
index 0000000..fec7c95
--- /dev/null
+++ b/apps/dashboard/app/(dashboard)/studio/train/page.tsx
@@ -0,0 +1,100 @@
+import { Suspense } from "react";
+import { notFound } from "next/navigation";
+import { PageHeader } from "../../../../components/page-header";
+import { AddContentForms } from "../../../../features/products/add-content-forms";
+import { ContentList } from "../../../../features/products/content-list";
+import { TrainActionsPanel } from "../../../../features/products/train-actions-panel";
+import { TestPreviewPanel } from "../../../../features/products/test-preview-panel";
+import {
+ getOrCreateProduct,
+ listActions,
+ listContent,
+} from "../../../../features/products/queries";
+
+export const dynamic = "force-dynamic";
+
+/** Placeholder rows shown while a list streams in. */
+function ListSkeleton() {
+ return (
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ );
+}
+
+/** The "Your content" list, fetched on its own so it streams in after the shell. */
+async function ContentListSection({ productId }: { productId: string }) {
+ const items = await listContent(productId);
+ return (
+
+
+
Your content
+
+ {items.length} {items.length === 1 ? "source" : "sources"}
+
+
+
+
+ );
+}
+
+/** The actions panel, fetched on its own so it streams in after the shell. */
+async function ActionsSection({ productId }: { productId: string }) {
+ const items = await listActions(productId);
+ return ;
+}
+
+export default async function StudioTrainPage() {
+ // Resolve the product once (needed for the preview + status). The content and
+ // actions lists stream in via Suspense, so the shell + preview paint right
+ // away instead of blocking on every DB round-trip up front.
+ const product = await getOrCreateProduct();
+ if (!product) notFound();
+
+ return (
+
+
+ Live
+
+ ) : (
+
+ Draft
+
+ )
+ }
+ />
+
+
+
+
+
+
Add content
+
+ Sources your agent can learn from. Enabled items are used in chat.
+
+
+
+ }>
+
+
+
+
+
}>
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/app/api/widget/[publicKey]/actions/run/route.ts b/apps/dashboard/app/api/widget/[publicKey]/actions/run/route.ts
new file mode 100644
index 0000000..b349aef
--- /dev/null
+++ b/apps/dashboard/app/api/widget/[publicKey]/actions/run/route.ts
@@ -0,0 +1,106 @@
+import { getProductByPublicKey } from "../../../../../../features/products/queries";
+import {
+ getEnabledActionForPublicKey,
+ runProductAction,
+} from "../../../../../../features/products/run-product-action";
+import { createRateLimiter } from "../../../../../../features/products/widget-chat";
+
+// Public confirm-gated action runner for the widget. Chat only proposes; this
+// endpoint runs after the visitor (or Test preview) confirms.
+export const runtime = "nodejs";
+export const maxDuration = 60;
+
+const allowRequest = createRateLimiter(20, 60_000);
+
+const CORS_HEADERS: Record = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+ "Access-Control-Max-Age": "86400",
+};
+
+interface RunBody {
+ actionId?: string;
+ /** Card that pays for capability runs (authenticated owner / Test). */
+ agentId?: string;
+ params?: string | Record;
+}
+
+function withCors(response: Response): Response {
+ const headers = new Headers(response.headers);
+ for (const [key, value] of Object.entries(CORS_HEADERS)) {
+ headers.set(key, value);
+ }
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers,
+ });
+}
+
+function json(data: unknown, status = 200): Response {
+ return withCors(
+ new Response(JSON.stringify(data), {
+ status,
+ headers: {
+ "content-type": "application/json",
+ "cache-control": "no-store",
+ },
+ }),
+ );
+}
+
+export async function OPTIONS() {
+ return withCors(new Response(null, { status: 204 }));
+}
+
+export async function POST(request: Request, context: { params: Promise<{ publicKey: string }> }) {
+ const { publicKey: rawKey } = await context.params;
+ const publicKey = decodeURIComponent(rawKey ?? "").trim();
+ if (!publicKey) return json({ error: "Missing public key." }, 400);
+
+ if (!allowRequest(publicKey)) {
+ return json({ error: "Too many requests. Try again in a minute." }, 429);
+ }
+
+ const product = await getProductByPublicKey(publicKey);
+ if (!product) return json({ error: "Agent not found." }, 404);
+
+ const preview = new URL(request.url).searchParams.get("preview") === "1";
+ if (product.status !== "live" && !preview) {
+ return json({ error: "Agent not found." }, 404);
+ }
+
+ const body = (await request.json().catch(() => null)) as RunBody | null;
+ const actionId = body?.actionId?.trim();
+ if (!actionId) return json({ error: "actionId is required." }, 400);
+
+ const owned = await getEnabledActionForPublicKey(publicKey, actionId);
+ if (!owned.ok) return json({ error: owned.error }, 404);
+
+ const result = await runProductAction({
+ actionId: owned.actionId,
+ agentId: body?.agentId,
+ params: body?.params,
+ });
+
+ if (!result.ok) {
+ return json(
+ {
+ ok: false,
+ error: result.error ?? "Could not run the action.",
+ status: result.status,
+ body: result.body,
+ },
+ result.error === "Not signed in." || result.error === "Not allowed." ? 401 : 400,
+ );
+ }
+
+ return json({
+ ok: true,
+ status: result.status,
+ body: result.body,
+ paid: result.paid,
+ txHash: result.txHash,
+ });
+}
diff --git a/apps/dashboard/app/api/widget/[publicKey]/chat/route.ts b/apps/dashboard/app/api/widget/[publicKey]/chat/route.ts
new file mode 100644
index 0000000..8a4f961
--- /dev/null
+++ b/apps/dashboard/app/api/widget/[publicKey]/chat/route.ts
@@ -0,0 +1,215 @@
+import {
+ getProductActionsForChat,
+ getProductByPublicKey,
+ getProductContentForChat,
+} from "../../../../../features/products/queries";
+import {
+ actionIdFromToolName,
+ buildWidgetSystemPrompt,
+ buildWidgetTools,
+ createRateLimiter,
+ proposeWidgetAction,
+ type ProposedWidgetAction,
+} from "../../../../../features/products/widget-chat";
+
+// Public per-product widget chat. Answers from that product's enabled content
+// and can PROPOSE enabled actions (confirm-gated; never runs them here).
+// No auth: the publicKey is the Stripe-style publishable key. Node runtime for
+// the OpenRouter key; room for a short tool loop.
+export const runtime = "nodejs";
+export const maxDuration = 60;
+
+const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
+const MODEL = process.env.OPENROUTER_MODEL ?? "google/gemini-2.5-flash";
+const MAX_TOKENS = 700;
+const MAX_MESSAGES = 20;
+const MAX_TOOL_HOPS = 3;
+
+const allowRequest = createRateLimiter(20, 60_000);
+
+const CORS_HEADERS: Record = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+ "Access-Control-Max-Age": "86400",
+};
+
+interface ToolCall {
+ id: string;
+ function: { name: string; arguments: string };
+}
+
+interface ChatMessage {
+ role: string;
+ content: string | null;
+ tool_calls?: ToolCall[];
+ tool_call_id?: string;
+ name?: string;
+}
+
+interface OpenRouterResponse {
+ choices?: { message?: ChatMessage }[];
+}
+
+interface WidgetChatBody {
+ messages?: { role: string; content: string }[];
+}
+
+function withCors(response: Response): Response {
+ const headers = new Headers(response.headers);
+ for (const [key, value] of Object.entries(CORS_HEADERS)) {
+ headers.set(key, value);
+ }
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers,
+ });
+}
+
+function json(data: unknown, status = 200): Response {
+ return withCors(
+ new Response(JSON.stringify(data), {
+ status,
+ headers: {
+ "content-type": "application/json",
+ "cache-control": "no-store",
+ },
+ }),
+ );
+}
+
+function reply(text: string, action: ProposedWidgetAction | null = null): Response {
+ return json({ reply: text, action });
+}
+
+function safeParseArgs(raw: string | undefined): Record {
+ if (!raw) return {};
+ try {
+ const v: unknown = JSON.parse(raw);
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {};
+ } catch {
+ return {};
+ }
+}
+
+export async function OPTIONS() {
+ return withCors(new Response(null, { status: 204 }));
+}
+
+export async function POST(request: Request, context: { params: Promise<{ publicKey: string }> }) {
+ const { publicKey: rawKey } = await context.params;
+ const publicKey = decodeURIComponent(rawKey ?? "").trim();
+ if (!publicKey) return json({ error: "Missing public key." }, 400);
+
+ if (!allowRequest(publicKey)) {
+ return json({ error: "Too many requests. Try again in a minute." }, 429);
+ }
+
+ const apiKey = process.env.OPENROUTER_API_KEY;
+ if (!apiKey) {
+ return json({ error: "The agent is not configured yet." }, 503);
+ }
+
+ const product = await getProductByPublicKey(publicKey);
+ if (!product) return json({ error: "Agent not found." }, 404);
+
+ const preview = new URL(request.url).searchParams.get("preview") === "1";
+ if (product.status !== "live" && !preview) {
+ return json({ error: "Agent not found." }, 404);
+ }
+
+ const body = (await request.json().catch(() => null)) as WidgetChatBody | null;
+ const messages = body?.messages;
+ if (!Array.isArray(messages) || messages.length === 0) {
+ return json({ error: "messages is required." }, 400);
+ }
+
+ const cleaned: { role: "user" | "assistant"; content: string }[] = [];
+ for (const m of messages) {
+ if (!m || (m.role !== "user" && m.role !== "assistant")) continue;
+ if (typeof m.content !== "string" || !m.content.trim()) continue;
+ cleaned.push({ role: m.role, content: m.content.trim().slice(0, 4000) });
+ }
+ if (cleaned.length === 0 || cleaned[cleaned.length - 1]?.role !== "user") {
+ return json({ error: "The last message must be from the user." }, 400);
+ }
+
+ const [content, actions] = await Promise.all([
+ getProductContentForChat(product.id),
+ getProductActionsForChat(product.id),
+ ]);
+ const system = buildWidgetSystemPrompt(product, content, actions);
+ const tools = buildWidgetTools(actions);
+ const actionsById = new Map(actions.map((a) => [a.id, a]));
+
+ const convo: ChatMessage[] = [
+ { role: "system", content: system },
+ ...cleaned.slice(-MAX_MESSAGES),
+ ];
+
+ try {
+ for (let hop = 0; hop < MAX_TOOL_HOPS; hop += 1) {
+ const resp = await fetch(OPENROUTER_URL, {
+ method: "POST",
+ headers: {
+ authorization: `Bearer ${apiKey}`,
+ "content-type": "application/json",
+ "HTTP-Referer": "https://taelprotocol.xyz",
+ "X-Title": "Tael Widget Agent",
+ },
+ body: JSON.stringify({
+ model: MODEL,
+ messages: convo,
+ ...(tools.length > 0 ? { tools, tool_choice: "auto" } : {}),
+ max_tokens: MAX_TOKENS,
+ temperature: 0.3,
+ }),
+ });
+
+ if (!resp.ok) {
+ return json({ error: "The agent is unavailable right now. Please try again." }, 502);
+ }
+
+ const data = (await resp.json()) as OpenRouterResponse;
+ const message = data.choices?.[0]?.message;
+ if (!message) {
+ return json({ error: "No response from the agent. Please try again." }, 502);
+ }
+
+ if (message.tool_calls?.length) {
+ // A write proposal is terminal: resolve the first matching action tool
+ // and return a confirm card. Do not execute server-side.
+ for (const call of message.tool_calls) {
+ const actionId = actionIdFromToolName(call.function.name);
+ if (!actionId) continue;
+ const action = actionsById.get(actionId);
+ if (!action) continue;
+ const proposed = proposeWidgetAction(action, safeParseArgs(call.function.arguments));
+ return reply(proposed.reply, proposed.action);
+ }
+ // Unknown tools: tell the model and continue (should be rare).
+ convo.push(message);
+ for (const call of message.tool_calls) {
+ convo.push({
+ role: "tool",
+ tool_call_id: call.id,
+ name: call.function.name,
+ content: JSON.stringify({ error: "unknown or disabled action" }),
+ });
+ }
+ continue;
+ }
+
+ const text = typeof message.content === "string" ? message.content.trim() : "";
+ if (!text) {
+ return json({ error: "No response from the agent. Please try again." }, 502);
+ }
+ return reply(text, null);
+ }
+
+ return reply("I couldn't quite finish that. Try rephrasing?");
+ } catch {
+ return json({ error: "Something went wrong. Please try again." }, 502);
+ }
+}
diff --git a/apps/dashboard/app/api/widget/[publicKey]/config/route.ts b/apps/dashboard/app/api/widget/[publicKey]/config/route.ts
new file mode 100644
index 0000000..ed51fe5
--- /dev/null
+++ b/apps/dashboard/app/api/widget/[publicKey]/config/route.ts
@@ -0,0 +1,68 @@
+import { getProductByPublicKey } from "../../../../../features/products/queries";
+import { createRateLimiter } from "../../../../../features/products/widget-chat";
+
+// Public branding config for the embed widget. Returns only safe display fields
+// (no ownerId or other private data).
+export const runtime = "nodejs";
+
+const allowRequest = createRateLimiter(60, 60_000);
+
+const CORS_HEADERS: Record = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+ "Access-Control-Max-Age": "86400",
+};
+
+function withCors(response: Response): Response {
+ const headers = new Headers(response.headers);
+ for (const [key, value] of Object.entries(CORS_HEADERS)) {
+ headers.set(key, value);
+ }
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers,
+ });
+}
+
+function json(data: unknown, status = 200): Response {
+ return withCors(
+ new Response(JSON.stringify(data), {
+ status,
+ headers: {
+ "content-type": "application/json",
+ "cache-control": "public, max-age=60",
+ },
+ }),
+ );
+}
+
+export async function OPTIONS() {
+ return withCors(new Response(null, { status: 204 }));
+}
+
+export async function GET(request: Request, context: { params: Promise<{ publicKey: string }> }) {
+ const { publicKey: rawKey } = await context.params;
+ const publicKey = decodeURIComponent(rawKey ?? "").trim();
+ if (!publicKey) return json({ error: "Missing public key." }, 400);
+
+ if (!allowRequest(publicKey)) {
+ return json({ error: "Too many requests. Try again in a minute." }, 429);
+ }
+
+ const product = await getProductByPublicKey(publicKey);
+ if (!product) return json({ error: "Agent not found." }, 404);
+
+ const preview = new URL(request.url).searchParams.get("preview") === "1";
+ if (product.status !== "live" && !preview) {
+ return json({ error: "Agent not found." }, 404);
+ }
+
+ return json({
+ name: product.name,
+ greeting: product.greeting,
+ brandColor: product.brandColor,
+ logoUrl: product.logoUrl,
+ });
+}
diff --git a/apps/dashboard/features/navigation/nav.config.ts b/apps/dashboard/features/navigation/nav.config.ts
index 60820a9..cf8281e 100644
--- a/apps/dashboard/features/navigation/nav.config.ts
+++ b/apps/dashboard/features/navigation/nav.config.ts
@@ -1,12 +1,17 @@
import {
BarChart3,
+ BookOpen,
Boxes,
Building2,
CreditCard,
+ FlaskConical,
+ Inbox,
KeyRound,
LayoutDashboard,
ArrowLeftRight,
+ Rocket,
Settings,
+ SlidersHorizontal,
Star,
Store,
Wallet,
@@ -44,6 +49,16 @@ export const navGroups: NavGroup[] = [
{ label: "Cards", href: "/agents", icon: CreditCard },
],
},
+ {
+ label: "Studio",
+ items: [
+ { label: "Train", href: "/studio/train", icon: BookOpen },
+ { label: "Test", href: "/studio/test", icon: FlaskConical },
+ { label: "Deploy", href: "/studio/deploy", icon: Rocket },
+ { label: "Inbox", href: "/studio/inbox", icon: Inbox },
+ { label: "Settings", href: "/studio/settings", icon: SlidersHorizontal },
+ ],
+ },
{
label: "Insights",
items: [
diff --git a/apps/dashboard/features/products/action-actions.ts b/apps/dashboard/features/products/action-actions.ts
new file mode 100644
index 0000000..5b11bd1
--- /dev/null
+++ b/apps/dashboard/features/products/action-actions.ts
@@ -0,0 +1,195 @@
+"use server";
+
+import { z } from "zod";
+import { and, eq, productActions, products } from "@tael/database";
+import { db } from "../../lib/db";
+import { getCurrentUser } from "../capabilities/current-user";
+import { type ActionResult } from "./actions";
+import { revalidateStudioPaths } from "./revalidate";
+
+const nameSchema = z.string().trim().min(1, "Name is required").max(80);
+const descriptionSchema = z.string().trim().min(1, "Description is required").max(1000);
+const httpMethodSchema = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]);
+
+const capabilityConfigSchema = z.object({
+ slug: z.string().trim().min(1, "Capability slug is required").max(120),
+});
+
+const httpConfigSchema = z.object({
+ url: z.string().trim().url("Enter a valid URL").max(2000),
+ method: httpMethodSchema,
+ paramsSchema: z.record(z.string(), z.unknown()).optional(),
+});
+
+const addActionSchema = z.discriminatedUnion("kind", [
+ z.object({
+ name: nameSchema,
+ description: descriptionSchema,
+ kind: z.literal("capability"),
+ config: capabilityConfigSchema,
+ shareAsCapability: z.boolean().optional(),
+ }),
+ z.object({
+ name: nameSchema,
+ description: descriptionSchema,
+ kind: z.literal("http"),
+ config: httpConfigSchema,
+ shareAsCapability: z.boolean().optional(),
+ }),
+]);
+
+const updateActionSchema = z.object({
+ name: nameSchema.optional(),
+ description: descriptionSchema.optional(),
+ shareAsCapability: z.boolean().optional(),
+ config: z.union([capabilityConfigSchema, httpConfigSchema]).optional(),
+});
+
+async function assertProductOwner(
+ productId: string,
+ userId: string,
+): Promise<{ ok: true } | { ok: false; error: string }> {
+ const [row] = await db
+ .select({ id: products.id })
+ .from(products)
+ .where(and(eq(products.id, productId), eq(products.ownerId, userId)))
+ .limit(1);
+ if (!row) return { ok: false, error: "Agent not found." };
+ return { ok: true };
+}
+
+async function assertActionOwner(
+ actionId: string,
+ userId: string,
+): Promise<{ ok: true; productId: string } | { ok: false; error: string }> {
+ const [row] = await db
+ .select({ id: productActions.id, productId: productActions.productId })
+ .from(productActions)
+ .innerJoin(products, eq(productActions.productId, products.id))
+ .where(and(eq(productActions.id, actionId), eq(products.ownerId, userId)))
+ .limit(1);
+ if (!row) return { ok: false, error: "Action not found." };
+ return { ok: true, productId: row.productId };
+}
+
+export type AddActionInput = z.infer;
+
+/** Insert an action for a product the signed-in user owns. */
+export async function addAction(productId: string, input: AddActionInput): Promise {
+ const user = await getCurrentUser();
+ if (!user) return { ok: false, error: "Not signed in." };
+
+ const parsed = addActionSchema.safeParse(input);
+ if (!parsed.success) {
+ return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." };
+ }
+
+ const owned = await assertProductOwner(productId, user.id);
+ if (!owned.ok) return owned;
+
+ try {
+ const [row] = await db
+ .insert(productActions)
+ .values({
+ productId,
+ name: parsed.data.name,
+ description: parsed.data.description,
+ kind: parsed.data.kind,
+ config: parsed.data.config,
+ shareAsCapability: parsed.data.shareAsCapability ?? false,
+ })
+ .returning({ id: productActions.id });
+
+ revalidateStudioPaths();
+ return { ok: true, id: row!.id };
+ } catch {
+ return { ok: false, error: "Could not add action. Try again." };
+ }
+}
+
+/** Update an action. Ownership-checked via product join. */
+export async function updateAction(
+ actionId: string,
+ input: z.infer,
+): Promise {
+ const user = await getCurrentUser();
+ if (!user) return { ok: false, error: "Not signed in." };
+
+ const parsed = updateActionSchema.safeParse(input);
+ if (!parsed.success) {
+ return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." };
+ }
+
+ const owned = await assertActionOwner(actionId, user.id);
+ if (!owned.ok) return owned;
+
+ const patch = parsed.data;
+ if (Object.keys(patch).length === 0) {
+ return { ok: false, error: "Nothing to update." };
+ }
+
+ // If config is patched, keep it aligned with the row's kind.
+ if (patch.config) {
+ const [existing] = await db
+ .select({ kind: productActions.kind })
+ .from(productActions)
+ .where(eq(productActions.id, actionId))
+ .limit(1);
+ if (!existing) return { ok: false, error: "Action not found." };
+ if (existing.kind === "capability") {
+ const cfg = capabilityConfigSchema.safeParse(patch.config);
+ if (!cfg.success) {
+ return { ok: false, error: cfg.error.issues[0]?.message ?? "Invalid config." };
+ }
+ patch.config = cfg.data;
+ } else {
+ const cfg = httpConfigSchema.safeParse(patch.config);
+ if (!cfg.success) {
+ return { ok: false, error: cfg.error.issues[0]?.message ?? "Invalid config." };
+ }
+ patch.config = cfg.data;
+ }
+ }
+
+ try {
+ await db.update(productActions).set(patch).where(eq(productActions.id, actionId));
+ revalidateStudioPaths();
+ return { ok: true, id: actionId };
+ } catch {
+ return { ok: false, error: "Could not save. Try again." };
+ }
+}
+
+/** Delete an action. Ownership-checked via product join. */
+export async function deleteAction(actionId: string): Promise {
+ const user = await getCurrentUser();
+ if (!user) return { ok: false, error: "Not signed in." };
+
+ const owned = await assertActionOwner(actionId, user.id);
+ if (!owned.ok) return owned;
+
+ try {
+ await db.delete(productActions).where(eq(productActions.id, actionId));
+ revalidateStudioPaths();
+ return { ok: true };
+ } catch {
+ return { ok: false, error: "Could not delete. Try again." };
+ }
+}
+
+/** Enable or disable an action. Ownership-checked via product join. */
+export async function toggleAction(actionId: string, enabled: boolean): Promise {
+ const user = await getCurrentUser();
+ if (!user) return { ok: false, error: "Not signed in." };
+
+ const owned = await assertActionOwner(actionId, user.id);
+ if (!owned.ok) return owned;
+
+ try {
+ await db.update(productActions).set({ enabled }).where(eq(productActions.id, actionId));
+ revalidateStudioPaths();
+ return { ok: true, id: actionId };
+ } catch {
+ return { ok: false, error: "Could not update. Try again." };
+ }
+}
diff --git a/apps/dashboard/features/products/action-dialogs.tsx b/apps/dashboard/features/products/action-dialogs.tsx
new file mode 100644
index 0000000..b8edbc5
--- /dev/null
+++ b/apps/dashboard/features/products/action-dialogs.tsx
@@ -0,0 +1,213 @@
+"use client";
+
+import { useState, useTransition, type ReactNode } from "react";
+import { useRouter } from "next/navigation";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ Input,
+ Textarea,
+} from "@tael/ui";
+import { addAction, type AddActionInput } from "./action-actions";
+
+function Field({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+type Kind = "capability" | "http";
+
+/** Dialog to register a capability or HTTP action for the product agent. */
+export function ConnectActionDialog({
+ open,
+ onClose,
+ productId,
+}: {
+ open: boolean;
+ onClose: () => void;
+ productId: string;
+}) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [kind, setKind] = useState("capability");
+ const [slug, setSlug] = useState("");
+ const [url, setUrl] = useState("");
+ const [method, setMethod] = useState<"GET" | "POST" | "PUT" | "PATCH" | "DELETE">("POST");
+ const [shareAsCapability, setShareAsCapability] = useState(false);
+ const [error, setError] = useState(null);
+
+ function reset() {
+ setName("");
+ setDescription("");
+ setKind("capability");
+ setSlug("");
+ setUrl("");
+ setMethod("POST");
+ setShareAsCapability(false);
+ setError(null);
+ }
+
+ function submit() {
+ setError(null);
+ startTransition(async () => {
+ const input: AddActionInput =
+ kind === "capability"
+ ? {
+ name,
+ description,
+ kind: "capability",
+ config: { slug },
+ shareAsCapability,
+ }
+ : {
+ name,
+ description,
+ kind: "http",
+ config: { url, method },
+ shareAsCapability,
+ };
+
+ const res = await addAction(productId, input);
+ if (res.ok) {
+ reset();
+ onClose();
+ router.refresh();
+ } else {
+ setError(res.error ?? "Could not add action.");
+ }
+ });
+ }
+
+ const canSubmit =
+ name.trim().length > 0 &&
+ description.trim().length > 0 &&
+ (kind === "capability" ? slug.trim().length > 0 : url.trim().length > 0);
+
+ return (
+ {
+ if (!o) {
+ reset();
+ onClose();
+ }
+ }}
+ >
+
+
+ Connect action
+
+ Register a capability or HTTP endpoint this agent can propose to visitors.
+
+
+
+
+ setName(e.target.value)}
+ placeholder="e.g. Book a demo"
+ autoFocus
+ />
+
+
+
+
+
+ Kind
+
+ setKind("capability")}
+ className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
+ kind === "capability"
+ ? "border-foreground bg-foreground text-background"
+ : "border-border text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ Capability
+
+ setKind("http")}
+ className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
+ kind === "http"
+ ? "border-foreground bg-foreground text-background"
+ : "border-border text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ HTTP
+
+
+
+
+ {kind === "capability" ? (
+
+ setSlug(e.target.value)}
+ placeholder="e.g. stellar-pay"
+ className="font-mono text-sm"
+ />
+
+ ) : (
+ <>
+
+ setUrl(e.target.value)}
+ placeholder="https://api.example.com/hook"
+ />
+
+
+
+ setMethod(e.target.value as "GET" | "POST" | "PUT" | "PATCH" | "DELETE")
+ }
+ className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
+ >
+ {(["GET", "POST", "PUT", "PATCH", "DELETE"] as const).map((m) => (
+
+ {m}
+
+ ))}
+
+
+ >
+ )}
+
+
+ setShareAsCapability(e.target.checked)}
+ className="h-4 w-4 accent-foreground"
+ />
+ Share as capability
+
+
+ {error ?
{error}
: null}
+
+ {pending ? "Saving…" : "Connect action"}
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/features/products/actions.ts b/apps/dashboard/features/products/actions.ts
new file mode 100644
index 0000000..a4a69b6
--- /dev/null
+++ b/apps/dashboard/features/products/actions.ts
@@ -0,0 +1,85 @@
+"use server";
+
+import { z } from "zod";
+import { and, eq, products } from "@tael/database";
+import { db } from "../../lib/db";
+import { getCurrentUser } from "../capabilities/current-user";
+import { revalidateStudioPaths } from "./revalidate";
+
+const nameSchema = z.string().trim().min(1, "Name is required").max(80);
+const brandColorSchema = z
+ .string()
+ .trim()
+ .regex(/^#[0-9A-Fa-f]{6}$/, "Use a hex color like #156DFC");
+const greetingSchema = z.string().max(500);
+const descriptionSchema = z.string().max(1000);
+const statusSchema = z.enum(["draft", "live"]);
+
+export interface ActionResult {
+ ok: boolean;
+ error?: string;
+ id?: string;
+}
+
+const updateProductSchema = z.object({
+ name: nameSchema.optional(),
+ description: descriptionSchema.optional(),
+ brandColor: brandColorSchema.optional(),
+ greeting: greetingSchema.optional(),
+ status: statusSchema.optional(),
+ logoUrl: z.string().max(200_000).nullable().optional(),
+});
+
+/** Update product settings. Ownership-checked. */
+export async function updateProduct(
+ id: string,
+ input: z.infer,
+): Promise {
+ const user = await getCurrentUser();
+ if (!user) return { ok: false, error: "Not signed in." };
+
+ const parsed = updateProductSchema.safeParse(input);
+ if (!parsed.success) {
+ return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." };
+ }
+
+ const patch = parsed.data;
+ if (Object.keys(patch).length === 0) {
+ return { ok: false, error: "Nothing to update." };
+ }
+
+ try {
+ const result = await db
+ .update(products)
+ .set(patch)
+ .where(and(eq(products.id, id), eq(products.ownerId, user.id)))
+ .returning({ id: products.id });
+
+ if (!result[0]) return { ok: false, error: "Agent not found." };
+
+ revalidateStudioPaths();
+ return { ok: true, id };
+ } catch {
+ return { ok: false, error: "Could not save. Try again." };
+ }
+}
+
+/** Delete a product the signed-in user owns. Cascades content + actions. */
+export async function deleteProduct(id: string): Promise {
+ const user = await getCurrentUser();
+ if (!user) return { ok: false, error: "Not signed in." };
+
+ try {
+ const result = await db
+ .delete(products)
+ .where(and(eq(products.id, id), eq(products.ownerId, user.id)))
+ .returning({ id: products.id });
+
+ if (!result[0]) return { ok: false, error: "Agent not found." };
+
+ revalidateStudioPaths();
+ return { ok: true };
+ } catch {
+ return { ok: false, error: "Could not delete. Try again." };
+ }
+}
diff --git a/apps/dashboard/features/products/add-content-forms.tsx b/apps/dashboard/features/products/add-content-forms.tsx
new file mode 100644
index 0000000..6ad2283
--- /dev/null
+++ b/apps/dashboard/features/products/add-content-forms.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import { useState } from "react";
+import { FileText, Globe, HelpCircle, Quote, type LucideIcon } from "lucide-react";
+import { cn } from "@tael/ui";
+import { FaqDialog, TextContentDialog, WebsiteDialog } from "./content-dialogs";
+
+type Mode = "website" | "doc" | "snippet" | "faq" | null;
+
+const ADD_CARDS: {
+ id: NonNullable;
+ label: string;
+ description: string;
+ icon: LucideIcon;
+ tone: string;
+}[] = [
+ {
+ id: "website",
+ label: "Website",
+ description: "Sync a page",
+ icon: Globe,
+ tone: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
+ },
+ {
+ id: "doc",
+ label: "Doc",
+ description: "Longer text",
+ icon: FileText,
+ tone: "bg-sky-500/10 text-sky-700 dark:text-sky-300",
+ },
+ {
+ id: "snippet",
+ label: "Snippet",
+ description: "Short note",
+ icon: Quote,
+ tone: "bg-violet-500/10 text-violet-700 dark:text-violet-300",
+ },
+ {
+ id: "faq",
+ label: "FAQ",
+ description: "Q & A",
+ icon: HelpCircle,
+ tone: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
+ },
+];
+
+/** Fin-style add-content cards; each opens an existing dialog. */
+export function AddContentForms({ productId }: { productId: string }) {
+ const [mode, setMode] = useState(null);
+
+ return (
+
+
+ {ADD_CARDS.map((card) => {
+ const Icon = card.icon;
+ return (
+ setMode(card.id)}
+ className={cn(
+ "group flex flex-col items-start gap-3 rounded-xl border bg-background p-4 text-left transition-colors",
+ "hover:border-foreground/20 hover:bg-muted/40 active:scale-[0.99]",
+ )}
+ >
+
+
+
+
+ {card.label}
+
+ {card.description}
+
+
+
+ );
+ })}
+
+
+
setMode(null)}
+ productId={productId}
+ />
+ setMode(null)}
+ productId={productId}
+ type="doc"
+ titleLabel="Title"
+ bodyLabel="Document"
+ bodyPlaceholder="Paste the full document the agent should know…"
+ dialogTitle="Add doc"
+ dialogDescription="A longer document the agent can quote from."
+ />
+ setMode(null)}
+ productId={productId}
+ type="snippet"
+ titleLabel="Title"
+ bodyLabel="Snippet"
+ bodyPlaceholder="A short note, policy line, or fact…"
+ dialogTitle="Add snippet"
+ dialogDescription="A short piece of text the agent can reference."
+ />
+ setMode(null)} productId={productId} />
+
+ );
+}
diff --git a/apps/dashboard/features/products/content-actions.ts b/apps/dashboard/features/products/content-actions.ts
new file mode 100644
index 0000000..54ba0f2
--- /dev/null
+++ b/apps/dashboard/features/products/content-actions.ts
@@ -0,0 +1,279 @@
+"use server";
+
+import { z } from "zod";
+import { and, eq, productContent, products } from "@tael/database";
+import { db } from "../../lib/db";
+import { getCurrentUser } from "../capabilities/current-user";
+import { type ActionResult } from "./actions";
+import { revalidateStudioPaths } from "./revalidate";
+import { extractTitle, htmlToText, MAX_BODY_BYTES } from "./html-to-text";
+
+const FETCH_TIMEOUT_MS = 15_000;
+
+const contentTypeSchema = z.enum(["doc", "snippet", "website", "faq"]);
+const titleSchema = z.string().trim().min(1, "Title is required").max(200);
+const bodySchema = z.string().trim().min(1, "Content is required").max(MAX_BODY_BYTES);
+const sourceUrlSchema = z.string().url("Enter a valid URL").max(2000).optional();
+
+/**
+ * Basic SSRF guard: reject non-http(s) URLs and obviously-internal hosts.
+ * Mirrors apps/api/src/modules/gateway/upstream.ts `isBlockedUrl`.
+ */
+function isBlockedUrl(raw: string): boolean {
+ let url: URL;
+ try {
+ url = new URL(raw);
+ } catch {
+ return true;
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:") return true;
+ const host = url.hostname;
+ return (
+ host === "localhost" ||
+ host === "0.0.0.0" ||
+ host.endsWith(".local") ||
+ /^127\./.test(host) ||
+ /^10\./.test(host) ||
+ /^192\.168\./.test(host) ||
+ /^169\.254\./.test(host) ||
+ /^172\.(1[6-9]|2\d|3[01])\./.test(host)
+ );
+}
+
+/**
+ * Fetch a URL while re-validating every redirect hop against the SSRF guard, so
+ * a public page cannot 302 us onto an internal host (cloud metadata, localhost).
+ * Uses manual redirects and follows up to `maxHops` of them.
+ */
+async function fetchNoSsrf(startUrl: string, maxHops = 4): Promise {
+ let current = startUrl;
+ for (let hop = 0; hop <= maxHops; hop += 1) {
+ if (isBlockedUrl(current)) throw new Error("blocked");
+ const res = await fetch(current, {
+ redirect: "manual",
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
+ headers: {
+ Accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
+ "User-Agent": "TaelStudioBot/1.0 (+https://taelprotocol.xyz)",
+ },
+ });
+ if (res.status >= 300 && res.status < 400) {
+ const location = res.headers.get("location");
+ if (!location) return res;
+ current = new URL(location, current).toString();
+ continue;
+ }
+ return res;
+ }
+ throw new Error("too many redirects");
+}
+
+async function assertProductOwner(
+ productId: string,
+ userId: string,
+): Promise<{ ok: true } | { ok: false; error: string }> {
+ const [row] = await db
+ .select({ id: products.id })
+ .from(products)
+ .where(and(eq(products.id, productId), eq(products.ownerId, userId)))
+ .limit(1);
+ if (!row) return { ok: false, error: "Agent not found." };
+ return { ok: true };
+}
+
+async function assertContentOwner(
+ contentId: string,
+ userId: string,
+): Promise<{ ok: true; productId: string } | { ok: false; error: string }> {
+ const [row] = await db
+ .select({ id: productContent.id, productId: productContent.productId })
+ .from(productContent)
+ .innerJoin(products, eq(productContent.productId, products.id))
+ .where(and(eq(productContent.id, contentId), eq(products.ownerId, userId)))
+ .limit(1);
+ if (!row) return { ok: false, error: "Content not found." };
+ return { ok: true, productId: row.productId };
+}
+
+const addContentSchema = z.object({
+ type: contentTypeSchema,
+ title: titleSchema,
+ body: bodySchema,
+ sourceUrl: sourceUrlSchema,
+});
+
+/** Insert a content row for a product the signed-in user owns. */
+export async function addContent(
+ productId: string,
+ input: {
+ type: "doc" | "snippet" | "website" | "faq";
+ title: string;
+ body: string;
+ sourceUrl?: string;
+ },
+): Promise {
+ const user = await getCurrentUser();
+ if (!user) return { ok: false, error: "Not signed in." };
+
+ const parsed = addContentSchema.safeParse(input);
+ if (!parsed.success) {
+ return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." };
+ }
+
+ const owned = await assertProductOwner(productId, user.id);
+ if (!owned.ok) return owned;
+
+ try {
+ const [row] = await db
+ .insert(productContent)
+ .values({
+ productId,
+ type: parsed.data.type,
+ title: parsed.data.title,
+ body: parsed.data.body,
+ sourceUrl: parsed.data.sourceUrl ?? null,
+ })
+ .returning({ id: productContent.id });
+
+ revalidateStudioPaths();
+ return { ok: true, id: row!.id };
+ } catch {
+ return { ok: false, error: "Could not add content. Try again." };
+ }
+}
+
+const updateContentSchema = z.object({
+ title: titleSchema.optional(),
+ body: bodySchema.optional(),
+ sourceUrl: z.string().url("Enter a valid URL").max(2000).nullable().optional(),
+});
+
+/** Update a content row. Ownership-checked via product join. */
+export async function updateContent(
+ contentId: string,
+ input: { title?: string; body?: string; sourceUrl?: string | null },
+): Promise {
+ const user = await getCurrentUser();
+ if (!user) return { ok: false, error: "Not signed in." };
+
+ const parsed = updateContentSchema.safeParse(input);
+ if (!parsed.success) {
+ return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." };
+ }
+
+ const owned = await assertContentOwner(contentId, user.id);
+ if (!owned.ok) return owned;
+
+ const patch = parsed.data;
+ if (Object.keys(patch).length === 0) {
+ return { ok: false, error: "Nothing to update." };
+ }
+
+ try {
+ await db.update(productContent).set(patch).where(eq(productContent.id, contentId));
+ revalidateStudioPaths();
+ return { ok: true, id: contentId };
+ } catch {
+ return { ok: false, error: "Could not save. Try again." };
+ }
+}
+
+/** Delete a content row. Ownership-checked via product join. */
+export async function deleteContent(contentId: string): Promise {
+ const user = await getCurrentUser();
+ if (!user) return { ok: false, error: "Not signed in." };
+
+ const owned = await assertContentOwner(contentId, user.id);
+ if (!owned.ok) return owned;
+
+ try {
+ await db.delete(productContent).where(eq(productContent.id, contentId));
+ revalidateStudioPaths();
+ return { ok: true };
+ } catch {
+ return { ok: false, error: "Could not delete. Try again." };
+ }
+}
+
+/** Enable or disable a content row. Ownership-checked via product join. */
+export async function toggleContent(contentId: string, enabled: boolean): Promise {
+ const user = await getCurrentUser();
+ if (!user) return { ok: false, error: "Not signed in." };
+
+ const owned = await assertContentOwner(contentId, user.id);
+ if (!owned.ok) return owned;
+
+ try {
+ await db.update(productContent).set({ enabled }).where(eq(productContent.id, contentId));
+ revalidateStudioPaths();
+ return { ok: true, id: contentId };
+ } catch {
+ return { ok: false, error: "Could not update. Try again." };
+ }
+}
+
+/**
+ * Fetch a single page, extract readable text, and store it as website content.
+ * multi-page crawl is a follow-up.
+ */
+export async function syncWebsite(productId: string, url: string): Promise {
+ const user = await getCurrentUser();
+ if (!user) return { ok: false, error: "Not signed in." };
+
+ const parsed = z.string().trim().url("Enter a valid URL").max(2000).safeParse(url);
+ if (!parsed.success) {
+ return { ok: false, error: parsed.error.issues[0]?.message ?? "Enter a valid URL." };
+ }
+
+ if (isBlockedUrl(parsed.data)) {
+ return { ok: false, error: "That URL cannot be synced." };
+ }
+
+ const owned = await assertProductOwner(productId, user.id);
+ if (!owned.ok) return owned;
+
+ let html: string;
+ try {
+ const res = await fetchNoSsrf(parsed.data);
+ if (!res.ok) {
+ return { ok: false, error: `Could not fetch the page (${res.status}).` };
+ }
+ const contentType = res.headers.get("content-type") ?? "";
+ if (!/text\/html|application\/xhtml\+xml|text\/plain/i.test(contentType)) {
+ return { ok: false, error: "URL did not return an HTML page." };
+ }
+ // Cap the download before parsing so a huge response can't blow memory.
+ const buf = await res.arrayBuffer();
+ if (buf.byteLength > MAX_BODY_BYTES * 4) {
+ return { ok: false, error: "Page is too large to sync." };
+ }
+ html = new TextDecoder("utf-8").decode(buf);
+ } catch {
+ return { ok: false, error: "Could not reach that URL. Try again." };
+ }
+
+ const hostname = new URL(parsed.data).hostname;
+ const title = extractTitle(html, hostname);
+ const body = htmlToText(html);
+ if (!body) {
+ return { ok: false, error: "No readable text found on that page." };
+ }
+
+ try {
+ const [row] = await db
+ .insert(productContent)
+ .values({
+ productId,
+ type: "website",
+ title,
+ body,
+ sourceUrl: parsed.data,
+ })
+ .returning({ id: productContent.id });
+
+ revalidateStudioPaths();
+ return { ok: true, id: row!.id };
+ } catch {
+ return { ok: false, error: "Could not save the synced page. Try again." };
+ }
+}
diff --git a/apps/dashboard/features/products/content-dialogs.tsx b/apps/dashboard/features/products/content-dialogs.tsx
new file mode 100644
index 0000000..df7b575
--- /dev/null
+++ b/apps/dashboard/features/products/content-dialogs.tsx
@@ -0,0 +1,354 @@
+"use client";
+
+import { useEffect, useState, useTransition, type ReactNode } from "react";
+import { useRouter } from "next/navigation";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ Input,
+ Textarea,
+} from "@tael/ui";
+import type { ProductContent } from "@tael/database";
+import { addContent, syncWebsite, updateContent } from "./content-actions";
+
+function Field({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+export function WebsiteDialog({
+ open,
+ onClose,
+ productId,
+}: {
+ open: boolean;
+ onClose: () => void;
+ productId: string;
+}) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [url, setUrl] = useState("");
+ const [error, setError] = useState(null);
+
+ function reset() {
+ setUrl("");
+ setError(null);
+ }
+
+ function submit() {
+ setError(null);
+ startTransition(async () => {
+ const res = await syncWebsite(productId, url);
+ if (res.ok) {
+ reset();
+ onClose();
+ router.refresh();
+ } else {
+ setError(res.error ?? "Could not sync.");
+ }
+ });
+ }
+
+ return (
+ {
+ if (!o) {
+ reset();
+ onClose();
+ }
+ }}
+ >
+
+
+ Sync website
+
+ Fetches one page and stores its readable text. Multi-page crawl comes later.
+
+
+
+
+ setUrl(e.target.value)}
+ placeholder="https://help.example.com/refunds"
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && url.trim() && !pending) submit();
+ }}
+ />
+
+ {error ?
{error}
: null}
+
+ {pending ? "Syncing…" : "Sync page"}
+
+
+
+
+ );
+}
+
+export function TextContentDialog({
+ open,
+ onClose,
+ productId,
+ type,
+ titleLabel,
+ bodyLabel,
+ bodyPlaceholder,
+ dialogTitle,
+ dialogDescription,
+}: {
+ open: boolean;
+ onClose: () => void;
+ productId: string;
+ type: "doc" | "snippet";
+ titleLabel: string;
+ bodyLabel: string;
+ bodyPlaceholder: string;
+ dialogTitle: string;
+ dialogDescription: string;
+}) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [title, setTitle] = useState("");
+ const [body, setBody] = useState("");
+ const [error, setError] = useState(null);
+
+ function reset() {
+ setTitle("");
+ setBody("");
+ setError(null);
+ }
+
+ function submit() {
+ setError(null);
+ startTransition(async () => {
+ const res = await addContent(productId, { type, title, body });
+ if (res.ok) {
+ reset();
+ onClose();
+ router.refresh();
+ } else {
+ setError(res.error ?? "Could not add content.");
+ }
+ });
+ }
+
+ return (
+ {
+ if (!o) {
+ reset();
+ onClose();
+ }
+ }}
+ >
+
+
+ {dialogTitle}
+ {dialogDescription}
+
+
+
+ setTitle(e.target.value)} autoFocus />
+
+
+
+ {error ?
{error}
: null}
+
+ {pending ? "Saving…" : "Add content"}
+
+
+
+
+ );
+}
+
+export function FaqDialog({
+ open,
+ onClose,
+ productId,
+}: {
+ open: boolean;
+ onClose: () => void;
+ productId: string;
+}) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [question, setQuestion] = useState("");
+ const [answer, setAnswer] = useState("");
+ const [error, setError] = useState(null);
+
+ function reset() {
+ setQuestion("");
+ setAnswer("");
+ setError(null);
+ }
+
+ function submit() {
+ setError(null);
+ startTransition(async () => {
+ const res = await addContent(productId, {
+ type: "faq",
+ title: question,
+ body: answer,
+ });
+ if (res.ok) {
+ reset();
+ onClose();
+ router.refresh();
+ } else {
+ setError(res.error ?? "Could not add FAQ.");
+ }
+ });
+ }
+
+ return (
+ {
+ if (!o) {
+ reset();
+ onClose();
+ }
+ }}
+ >
+
+
+ Add FAQ
+
+ A question and answer the agent can use when users ask about it.
+
+
+
+
+ setQuestion(e.target.value)} autoFocus />
+
+
+
+ {error ?
{error}
: null}
+
+ {pending ? "Saving…" : "Add FAQ"}
+
+
+
+
+ );
+}
+
+/** Edit an existing content row (title + body). */
+export function EditContentDialog({
+ open,
+ onClose,
+ item,
+}: {
+ open: boolean;
+ onClose: () => void;
+ item: ProductContent | null;
+}) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [title, setTitle] = useState("");
+ const [body, setBody] = useState("");
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (open && item) {
+ setTitle(item.title);
+ setBody(item.body);
+ setError(null);
+ }
+ }, [open, item]);
+
+ function reset() {
+ setTitle("");
+ setBody("");
+ setError(null);
+ }
+
+ function submit() {
+ if (!item) return;
+ setError(null);
+ startTransition(async () => {
+ const res = await updateContent(item.id, { title, body });
+ if (res.ok) {
+ reset();
+ onClose();
+ router.refresh();
+ } else {
+ setError(res.error ?? "Could not save.");
+ }
+ });
+ }
+
+ const isFaq = item?.type === "faq";
+
+ return (
+ {
+ if (!o) {
+ reset();
+ onClose();
+ }
+ }}
+ >
+
+
+ Edit {isFaq ? "FAQ" : "content"}
+ Update what the agent can use from this source.
+
+
+
+ setTitle(e.target.value)} autoFocus />
+
+
+
+ {error ?
{error}
: null}
+
+ {pending ? "Saving…" : "Save changes"}
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/features/products/content-list.tsx b/apps/dashboard/features/products/content-list.tsx
new file mode 100644
index 0000000..071e047
--- /dev/null
+++ b/apps/dashboard/features/products/content-list.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import {
+ FileText,
+ Globe,
+ HelpCircle,
+ MoreHorizontal,
+ Pencil,
+ Power,
+ Quote,
+ Trash2,
+} from "lucide-react";
+import {
+ Badge,
+ Button,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+ cn,
+} from "@tael/ui";
+import type { ProductContent } from "@tael/database";
+import { deleteContent, toggleContent } from "./content-actions";
+import { EditContentDialog } from "./content-dialogs";
+
+const TYPE_META: Record<
+ ProductContent["type"],
+ { label: string; icon: typeof FileText; tone: string }
+> = {
+ doc: {
+ label: "Doc",
+ icon: FileText,
+ tone: "bg-sky-500/10 text-sky-700 dark:text-sky-300",
+ },
+ snippet: {
+ label: "Snippet",
+ icon: Quote,
+ tone: "bg-violet-500/10 text-violet-700 dark:text-violet-300",
+ },
+ website: {
+ label: "Website",
+ icon: Globe,
+ tone: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
+ },
+ faq: {
+ label: "FAQ",
+ icon: HelpCircle,
+ tone: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
+ },
+};
+
+export function ContentList({ items }: { items: ProductContent[] }) {
+ const [editing, setEditing] = useState(null);
+
+ if (items.length === 0) {
+ return (
+
+ No content yet. Add a website, doc, snippet, or FAQ to train this agent.
+
+ );
+ }
+
+ return (
+ <>
+
+
+ {items.map((item) => (
+ setEditing(item)} />
+ ))}
+
+
+ setEditing(null)} item={editing} />
+ >
+ );
+}
+
+function ContentRow({ item, onEdit }: { item: ProductContent; onEdit: () => void }) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [menuOpen, setMenuOpen] = useState(false);
+ const meta = TYPE_META[item.type];
+ const Icon = meta.icon;
+
+ function onToggle() {
+ startTransition(async () => {
+ await toggleContent(item.id, !item.enabled);
+ router.refresh();
+ });
+ }
+
+ function onDelete() {
+ startTransition(async () => {
+ await deleteContent(item.id);
+ router.refresh();
+ });
+ }
+
+ return (
+
+
+
+
+
+
+
{item.title}
+
{item.sourceUrl ?? meta.label}
+
+
+
+ {item.enabled ? "Enabled" : "Disabled"}
+
+
+
+
+
+
+
+
+
+ {
+ setMenuOpen(false);
+ onEdit();
+ }}
+ >
+ Edit
+
+ {
+ setMenuOpen(false);
+ onToggle();
+ }}
+ >
+ {item.enabled ? "Disable" : "Enable"}
+
+
+ {
+ setMenuOpen(false);
+ onDelete();
+ }}
+ >
+ Delete
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/features/products/deploy-panel.tsx b/apps/dashboard/features/products/deploy-panel.tsx
new file mode 100644
index 0000000..35f42a0
--- /dev/null
+++ b/apps/dashboard/features/products/deploy-panel.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Check, Copy, Globe, MessageCircle, Radio, Server } from "lucide-react";
+import { Button, cn } from "@tael/ui";
+import type { Product } from "@tael/database";
+
+const CHANNELS = [
+ {
+ id: "website",
+ label: "Website",
+ description: "Embed the chat widget on your site.",
+ icon: Globe,
+ status: "live" as const,
+ },
+ {
+ id: "discord",
+ label: "Discord",
+ description: "Answer in your Discord server.",
+ icon: MessageCircle,
+ status: "soon" as const,
+ },
+ {
+ id: "telegram",
+ label: "Telegram",
+ description: "A bot for your Telegram channel.",
+ icon: Radio,
+ status: "soon" as const,
+ },
+ {
+ id: "mcp",
+ label: "MCP",
+ description: "Expose this agent as an MCP server.",
+ icon: Server,
+ status: "soon" as const,
+ },
+];
+
+/** Deploy: embed snippet + channel availability. */
+export function DeployPanel({ product }: { product: Product }) {
+ const [copied, setCopied] = useState(false);
+ const [base, setBase] = useState(
+ () => process.env.NEXT_PUBLIC_DASHBOARD_URL?.replace(/\/$/, "") ?? "",
+ );
+
+ useEffect(() => {
+ if (!process.env.NEXT_PUBLIC_DASHBOARD_URL) {
+ setBase(window.location.origin);
+ }
+ }, []);
+
+ const snippet = base
+ ? ``
+ : ``;
+
+ async function copy() {
+ if (!base) return;
+ try {
+ await navigator.clipboard.writeText(
+ ``,
+ );
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ // no-op
+ }
+ }
+
+ return (
+
+
+
+
Embed snippet
+
+ Paste this before the closing body tag on any page. The widget loads from Tael and talks
+ to your agent.
+
+
+
+ {product.status !== "live" ? (
+
+ This agent is still a draft. Set it live in Settings so the embed works for visitors.
+
+ ) : null}
+
+
+
+
+
+ {copied ? : }
+ {copied ? "Copied" : "Copy snippet"}
+
+ {product.publicKey}
+
+
+
+
+
+
Channels
+
+ Where this agent can show up. Website is ready; more channels are on the way.
+
+
+
+
+ {CHANNELS.map((ch) => {
+ const Icon = ch.icon;
+ const live = ch.status === "live";
+ return (
+
+
+
+
+
+
{ch.label}
+
{ch.description}
+
+ {live ? (
+
+ Live
+
+ ) : (
+
+ Coming soon
+
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/dashboard/features/products/html-to-text.ts b/apps/dashboard/features/products/html-to-text.ts
new file mode 100644
index 0000000..8c8540d
--- /dev/null
+++ b/apps/dashboard/features/products/html-to-text.ts
@@ -0,0 +1,44 @@
+/** Cap for stored website body text (~100KB). */
+export const MAX_BODY_BYTES = 100 * 1024;
+
+/** Strip scripts/styles/tags and collapse whitespace into readable text. */
+export function htmlToText(html: string): string {
+ let text = html
+ .replace(/
+ * Optional: data-tael-base="https://dashboard-origin" (defaults to script origin)
+ */
+(function () {
+ "use strict";
+
+ var script =
+ document.currentScript || document.querySelector("script[data-tael-key][src*='embed.js']");
+ if (!script) return;
+
+ var key = (script.getAttribute("data-tael-key") || "").trim();
+ if (!key) return;
+
+ var baseAttr = (script.getAttribute("data-tael-base") || "").trim().replace(/\/$/, "");
+ var base = baseAttr;
+ if (!base) {
+ try {
+ base = new URL(script.src).origin;
+ } catch (e) {
+ base = window.location.origin;
+ }
+ }
+
+ var apiRoot = base + "/api/widget/" + encodeURIComponent(key);
+ var brand = "#156DFC";
+ var agentName = "Assistant";
+ var greeting = "";
+ var logoUrl = null;
+
+ var host = document.createElement("div");
+ host.id = "tael-widget-host";
+ host.setAttribute("data-tael", "1");
+ document.documentElement.appendChild(host);
+ var root = host.attachShadow({ mode: "open" });
+
+ var style = document.createElement("style");
+ style.textContent = [
+ ":host, * { box-sizing: border-box; }",
+ ":host { all: initial; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }",
+ ".wrap { position: fixed; z-index: 2147483000; right: 20px; bottom: 20px; display: flex; flex-direction: column; align-items: flex-end; gap: 12px; }",
+ ".launcher { width: 56px; height: 56px; border: none; border-radius: 999px; cursor: pointer; color: #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.28); transition: transform 0.15s ease, box-shadow 0.15s ease; }",
+ ".launcher:hover { transform: scale(1.04); box-shadow: 0 10px 28px rgba(15, 23, 42, 0.32); }",
+ ".launcher:active { transform: scale(0.98); }",
+ ".launcher svg { width: 24px; height: 24px; }",
+ ".panel { width: min(380px, calc(100vw - 32px)); height: min(560px, calc(100vh - 100px)); background: #0f1115; color: #f4f4f5; border-radius: 18px; overflow: hidden; display: none; flex-direction: column; box-shadow: 0 18px 50px rgba(0,0,0,0.45); border: 1px solid rgba(255,255,255,0.08); }",
+ ".panel.open { display: flex; }",
+ ".header { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,0.08); background: #15171c; }",
+ ".logo { width: 32px; height: 32px; border-radius: 10px; object-fit: cover; background: rgba(255,255,255,0.08); flex-shrink: 0; }",
+ ".logo.fallback { display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700; color: #fff; }",
+ ".title { min-width: 0; flex: 1; }",
+ ".title h1 { margin: 0; font-size: 14px; font-weight: 600; color: #fafafa; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }",
+ ".title p { margin: 2px 0 0; font-size: 11px; color: rgba(255,255,255,0.45); }",
+ ".close { border: none; background: transparent; color: rgba(255,255,255,0.5); cursor: pointer; width: 32px; height: 32px; border-radius: 8px; font-size: 18px; line-height: 1; }",
+ ".close:hover { background: rgba(255,255,255,0.08); color: #fff; }",
+ ".msgs { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; }",
+ ".bubble { max-width: 85%; padding: 10px 12px; border-radius: 16px; font-size: 13.5px; line-height: 1.45; word-break: break-word; }",
+ ".bubble.user { align-self: flex-end; background: #fff; color: #14161a; border-bottom-right-radius: 4px; }",
+ ".bubble.assistant { align-self: flex-start; background: #2c2d31; color: #f4f4f5; border-bottom-left-radius: 4px; }",
+ ".bubble.meta { align-self: flex-start; background: transparent; color: rgba(255,255,255,0.45); font-size: 12px; padding: 0 2px; }",
+ ".bubble strong { font-weight: 600; }",
+ ".confirm { align-self: flex-start; width: min(100%, 280px); border: 1px solid rgba(255,255,255,0.1); background: #1c1d21; border-radius: 14px; padding: 12px; }",
+ ".confirm .label { margin: 0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: rgba(255,255,255,0.4); }",
+ ".confirm .name { margin: 4px 0 0; font-size: 13.5px; font-weight: 600; color: #f4f4f5; }",
+ ".confirm button { margin-top: 10px; width: 100%; border: none; border-radius: 8px; padding: 8px 12px; font-size: 13px; font-weight: 600; cursor: pointer; color: #14161a; background: #fff; }",
+ ".confirm button:hover { background: #f4f4f5; }",
+ ".confirm button:disabled { opacity: 0.6; cursor: default; }",
+ ".footer { padding: 10px 12px 12px; border-top: 1px solid rgba(255,255,255,0.08); background: #15171c; }",
+ ".composer { display: flex; gap: 8px; align-items: flex-end; }",
+ ".composer textarea { flex: 1; resize: none; min-height: 40px; max-height: 96px; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); background: #0f1115; color: #f4f4f5; padding: 10px 12px; font: inherit; font-size: 13.5px; outline: none; }",
+ ".composer textarea:focus { border-color: var(--tael-brand, #156DFC); }",
+ ".composer button { width: 40px; height: 40px; border: none; border-radius: 12px; cursor: pointer; color: #fff; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }",
+ ".composer button:disabled { opacity: 0.5; cursor: default; }",
+ ".composer button svg { width: 16px; height: 16px; }",
+ ".typing { display: inline-flex; gap: 4px; padding: 4px 0; }",
+ ".typing i { width: 6px; height: 6px; border-radius: 999px; background: rgba(255,255,255,0.5); display: block; animation: taelPulse 1s ease-in-out infinite; }",
+ ".typing i:nth-child(2) { animation-delay: 0.15s; }",
+ ".typing i:nth-child(3) { animation-delay: 0.3s; }",
+ "@keyframes taelPulse { 0%, 100% { opacity: 0.25; } 50% { opacity: 1; } }",
+ "@media (max-width: 420px) { .wrap { right: 12px; bottom: 12px; } .panel { width: calc(100vw - 24px); height: calc(100vh - 88px); } }",
+ ].join("\n");
+ root.appendChild(style);
+
+ var wrap = document.createElement("div");
+ wrap.className = "wrap";
+ wrap.innerHTML = [
+ '',
+ ' ",
+ '
',
+ ' ",
+ "
",
+ '',
+ ' ',
+ " ",
+ ].join("");
+ root.appendChild(wrap);
+
+ var panel = wrap.querySelector(".panel");
+ var launcher = wrap.querySelector(".launcher");
+ var closeBtn = wrap.querySelector(".close");
+ var msgs = wrap.querySelector(".msgs");
+ var form = wrap.querySelector(".composer");
+ var input = wrap.querySelector("textarea");
+ var sendBtn = wrap.querySelector(".composer button");
+ var titleEl = wrap.querySelector(".title h1");
+ var logoEl = wrap.querySelector(".logo");
+
+ var open = false;
+ var busy = false;
+ var history = [];
+ var greeted = false;
+
+ function setBrand(color) {
+ brand = color || "#156DFC";
+ launcher.style.background = brand;
+ sendBtn.style.background = brand;
+ wrap.style.setProperty("--tael-brand", brand);
+ if (logoEl.classList.contains("fallback")) {
+ logoEl.style.background = brand;
+ }
+ }
+
+ function setOpen(next) {
+ open = next;
+ panel.classList.toggle("open", open);
+ launcher.setAttribute("aria-expanded", open ? "true" : "false");
+ launcher.setAttribute("aria-label", open ? "Close chat" : "Open chat");
+ if (open) {
+ if (!greeted && greeting) {
+ appendAssistant(greeting, null);
+ greeted = true;
+ }
+ input.focus();
+ scrollBottom();
+ }
+ }
+
+ function scrollBottom() {
+ msgs.scrollTop = msgs.scrollHeight;
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+ }
+
+ function formatText(s) {
+ var escaped = escapeHtml(s);
+ return escaped.replace(/\*\*([^*]+)\*\*/g, "$1 ").replace(/\n/g, " ");
+ }
+
+ function appendBubble(role, html) {
+ var el = document.createElement("div");
+ el.className = "bubble " + role;
+ el.innerHTML = html;
+ msgs.appendChild(el);
+ scrollBottom();
+ return el;
+ }
+
+ function appendAssistant(text, action) {
+ appendBubble("assistant", formatText(text || ""));
+ if (action) appendConfirm(action);
+ }
+
+ function appendConfirm(action) {
+ var card = document.createElement("div");
+ card.className = "confirm";
+ var label = document.createElement("p");
+ label.className = "label";
+ label.textContent = action.kind === "http" ? "Run action" : "Run capability";
+ var name = document.createElement("p");
+ name.className = "name";
+ name.textContent = action.name || "Action";
+ var btn = document.createElement("button");
+ btn.type = "button";
+ btn.textContent = "Confirm";
+ btn.addEventListener("click", function () {
+ runAction(action, card, btn);
+ });
+ card.appendChild(label);
+ card.appendChild(name);
+ card.appendChild(btn);
+ msgs.appendChild(card);
+ scrollBottom();
+ }
+
+ function setBusy(next) {
+ busy = next;
+ sendBtn.disabled = busy || !input.value.trim();
+ input.disabled = busy;
+ }
+
+ function showTyping() {
+ var el = document.createElement("div");
+ el.className = "bubble assistant";
+ el.innerHTML = ' ';
+ msgs.appendChild(el);
+ scrollBottom();
+ return el;
+ }
+
+ function applyConfig(cfg) {
+ if (!cfg) return;
+ agentName = cfg.name || agentName;
+ greeting = (cfg.greeting || "").trim();
+ logoUrl = cfg.logoUrl || null;
+ titleEl.textContent = agentName;
+ setBrand(cfg.brandColor);
+ if (logoUrl) {
+ var img = document.createElement("img");
+ img.className = "logo";
+ img.alt = "";
+ img.src = logoUrl;
+ logoEl.replaceWith(img);
+ logoEl = img;
+ } else {
+ logoEl.textContent = (agentName.charAt(0) || "T").toUpperCase();
+ logoEl.style.background = brand;
+ }
+ }
+
+ function loadConfig() {
+ return fetch(apiRoot + "/config", { method: "GET", credentials: "omit" })
+ .then(function (res) {
+ return res.json().then(function (data) {
+ if (!res.ok) throw new Error((data && data.error) || "Config failed");
+ return data;
+ });
+ })
+ .then(applyConfig)
+ .catch(function () {
+ titleEl.textContent = agentName;
+ setBrand(brand);
+ logoEl.textContent = "T";
+ logoEl.style.background = brand;
+ });
+ }
+
+ function sendMessage(text) {
+ var content = (text || "").trim();
+ if (!content || busy) return;
+ appendBubble("user", escapeHtml(content));
+ history.push({ role: "user", content: content });
+ input.value = "";
+ input.style.height = "auto";
+ setBusy(true);
+ var typing = showTyping();
+
+ fetch(apiRoot + "/chat", {
+ method: "POST",
+ credentials: "omit",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ messages: history.slice(-20) }),
+ })
+ .then(function (res) {
+ return res.json().then(function (data) {
+ return { res: res, data: data };
+ });
+ })
+ .then(function (out) {
+ typing.remove();
+ if (!out.res.ok) {
+ appendBubble(
+ "assistant",
+ escapeHtml((out.data && out.data.error) || "Something went wrong. Please try again."),
+ );
+ return;
+ }
+ var reply = (out.data && out.data.reply) || "";
+ var action = out.data && out.data.action ? out.data.action : null;
+ if (reply) history.push({ role: "assistant", content: reply });
+ appendAssistant(reply || "…", action);
+ })
+ .catch(function () {
+ typing.remove();
+ appendBubble("assistant", "Could not reach the agent. Please try again.");
+ })
+ .then(function () {
+ setBusy(false);
+ input.focus();
+ });
+ }
+
+ function runAction(action, card, btn) {
+ if (busy) return;
+ btn.disabled = true;
+ btn.textContent = "Running…";
+ setBusy(true);
+
+ var body = { actionId: action.actionId };
+ if (action.kind === "http" && action.params) body.params = action.params;
+ if (action.kind === "capability" && action.params) body.params = action.params;
+
+ fetch(apiRoot + "/actions/run", {
+ method: "POST",
+ credentials: "omit",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ })
+ .then(function (res) {
+ return res.json().then(function (data) {
+ return { res: res, data: data };
+ });
+ })
+ .then(function (out) {
+ card.remove();
+ if (out.res.status === 401 || (out.data && out.data.error === "Not signed in.")) {
+ appendBubble(
+ "assistant",
+ "This action needs the site owner to run it. It is not available to visitors.",
+ );
+ return;
+ }
+ if (!out.res.ok || !(out.data && out.data.ok)) {
+ appendBubble(
+ "assistant",
+ escapeHtml(
+ (out.data && out.data.error) || "Could not run that action. Please try again.",
+ ),
+ );
+ return;
+ }
+ var msg = "Ran **" + (action.name || "action") + "**.";
+ if (out.data.body) {
+ var clipped =
+ String(out.data.body).length > 400
+ ? String(out.data.body).slice(0, 400) + "…"
+ : String(out.data.body);
+ msg += "\n\n" + clipped;
+ }
+ appendAssistant(msg, null);
+ })
+ .catch(function () {
+ btn.disabled = false;
+ btn.textContent = "Confirm";
+ appendBubble("assistant", "Could not reach the server. Please try again.");
+ })
+ .then(function () {
+ setBusy(false);
+ });
+ }
+
+ launcher.addEventListener("click", function () {
+ setOpen(!open);
+ });
+ closeBtn.addEventListener("click", function () {
+ setOpen(false);
+ });
+
+ form.addEventListener("submit", function (e) {
+ e.preventDefault();
+ sendMessage(input.value);
+ });
+
+ input.addEventListener("input", function () {
+ sendBtn.disabled = busy || !input.value.trim();
+ input.style.height = "auto";
+ input.style.height = Math.min(input.scrollHeight, 96) + "px";
+ });
+
+ input.addEventListener("keydown", function (e) {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ sendMessage(input.value);
+ }
+ });
+
+ setBrand(brand);
+ titleEl.textContent = agentName;
+ loadConfig();
+})();
diff --git a/packages/database/drizzle/0016_common_captain_cross.sql b/packages/database/drizzle/0016_common_captain_cross.sql
new file mode 100644
index 0000000..d92d21a
--- /dev/null
+++ b/packages/database/drizzle/0016_common_captain_cross.sql
@@ -0,0 +1,52 @@
+CREATE TYPE "public"."product_action_kind" AS ENUM('capability', 'http');--> statement-breakpoint
+CREATE TYPE "public"."product_content_type" AS ENUM('doc', 'snippet', 'website', 'faq');--> statement-breakpoint
+CREATE TYPE "public"."product_status" AS ENUM('draft', 'live');--> statement-breakpoint
+CREATE TABLE "product_actions" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "product_id" uuid NOT NULL,
+ "name" text NOT NULL,
+ "description" text NOT NULL,
+ "kind" "product_action_kind" NOT NULL,
+ "config" jsonb NOT NULL,
+ "share_as_capability" boolean DEFAULT false NOT NULL,
+ "enabled" boolean DEFAULT true NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "product_content" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "product_id" uuid NOT NULL,
+ "type" "product_content_type" NOT NULL,
+ "title" text NOT NULL,
+ "body" text NOT NULL,
+ "source_url" text,
+ "enabled" boolean DEFAULT true NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "products" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "owner_id" uuid NOT NULL,
+ "slug" text NOT NULL,
+ "name" text NOT NULL,
+ "description" text DEFAULT '' NOT NULL,
+ "logo_url" text,
+ "brand_color" text DEFAULT '#156DFC' NOT NULL,
+ "greeting" text DEFAULT '' NOT NULL,
+ "public_key" text NOT NULL,
+ "status" "product_status" DEFAULT 'draft' NOT NULL,
+ "settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "products_slug_unique" UNIQUE("slug"),
+ CONSTRAINT "products_public_key_unique" UNIQUE("public_key")
+);
+--> statement-breakpoint
+ALTER TABLE "product_actions" ADD CONSTRAINT "product_actions_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "product_content" ADD CONSTRAINT "product_content_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "products" ADD CONSTRAINT "products_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "product_actions_product_id_idx" ON "product_actions" USING btree ("product_id");--> statement-breakpoint
+CREATE INDEX "product_content_product_id_idx" ON "product_content" USING btree ("product_id");--> statement-breakpoint
+CREATE INDEX "products_owner_id_idx" ON "products" USING btree ("owner_id");
\ No newline at end of file
diff --git a/packages/database/drizzle/meta/0016_snapshot.json b/packages/database/drizzle/meta/0016_snapshot.json
new file mode 100644
index 0000000..ac18a38
--- /dev/null
+++ b/packages/database/drizzle/meta/0016_snapshot.json
@@ -0,0 +1,1563 @@
+{
+ "id": "8b20e51b-87f7-4f26-9fd4-0fe3f7f4b7f3",
+ "prevId": "dc195d49-ccd7-43b7-b5f4-d250982fe045",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "wallet_address": {
+ "name": "wallet_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "users_wallet_address_idx": {
+ "name": "users_wallet_address_idx",
+ "columns": [
+ {
+ "expression": "wallet_address",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_wallet_address_unique": {
+ "name": "users_wallet_address_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "wallet_address"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.wallets": {
+ "name": "wallets",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "address": {
+ "name": "address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'Main'"
+ },
+ "secret_enc": {
+ "name": "secret_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "balance_cached": {
+ "name": "balance_cached",
+ "type": "numeric(20, 7)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "wallets_owner_id_idx": {
+ "name": "wallets_owner_id_idx",
+ "columns": [
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "wallets_owner_id_users_id_fk": {
+ "name": "wallets_owner_id_users_id_fk",
+ "tableFrom": "wallets",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "wallets_address_unique": {
+ "name": "wallets_address_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "address"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.capabilities": {
+ "name": "capabilities",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "logo_url": {
+ "name": "logo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contact": {
+ "name": "contact",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "capability_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "capability_visibility",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'public'"
+ },
+ "status": {
+ "name": "status",
+ "type": "capability_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "faqs": {
+ "name": "faqs",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "spec": {
+ "name": "spec",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "price": {
+ "name": "price",
+ "type": "numeric(20, 7)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pay_to": {
+ "name": "pay_to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "upstream_url": {
+ "name": "upstream_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "upstream_secret_enc": {
+ "name": "upstream_secret_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "upstream_auth": {
+ "name": "upstream_auth",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "billing": {
+ "name": "billing",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "publisher_id": {
+ "name": "publisher_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "capabilities_publisher_id_idx": {
+ "name": "capabilities_publisher_id_idx",
+ "columns": [
+ {
+ "expression": "publisher_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "capabilities_kind_idx": {
+ "name": "capabilities_kind_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "capabilities_visibility_idx": {
+ "name": "capabilities_visibility_idx",
+ "columns": [
+ {
+ "expression": "visibility",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "capabilities_publisher_id_users_id_fk": {
+ "name": "capabilities_publisher_id_users_id_fk",
+ "tableFrom": "capabilities",
+ "tableTo": "users",
+ "columnsFrom": [
+ "publisher_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "capabilities_slug_unique": {
+ "name": "capabilities_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agents": {
+ "name": "agents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "wallet_id": {
+ "name": "wallet_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "policy": {
+ "name": "policy",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "agents_owner_id_idx": {
+ "name": "agents_owner_id_idx",
+ "columns": [
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agents_owner_id_users_id_fk": {
+ "name": "agents_owner_id_users_id_fk",
+ "tableFrom": "agents",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agents_wallet_id_wallets_id_fk": {
+ "name": "agents_wallet_id_wallets_id_fk",
+ "tableFrom": "agents",
+ "tableTo": "wallets",
+ "columnsFrom": [
+ "wallet_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.payments": {
+ "name": "payments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "capability_id": {
+ "name": "capability_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "capability_name": {
+ "name": "capability_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payer": {
+ "name": "payer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payee": {
+ "name": "payee",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "amount": {
+ "name": "amount",
+ "type": "numeric(20, 7)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fee": {
+ "name": "fee",
+ "type": "numeric(20, 7)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "status": {
+ "name": "status",
+ "type": "payment_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "tx_hash": {
+ "name": "tx_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "payments_capability_id_idx": {
+ "name": "payments_capability_id_idx",
+ "columns": [
+ {
+ "expression": "capability_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payments_agent_id_idx": {
+ "name": "payments_agent_id_idx",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payments_payer_idx": {
+ "name": "payments_payer_idx",
+ "columns": [
+ {
+ "expression": "payer",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payments_payee_idx": {
+ "name": "payments_payee_idx",
+ "columns": [
+ {
+ "expression": "payee",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payments_status_idx": {
+ "name": "payments_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "payments_tx_hash_unique": {
+ "name": "payments_tx_hash_unique",
+ "columns": [
+ {
+ "expression": "tx_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "payments_capability_id_capabilities_id_fk": {
+ "name": "payments_capability_id_capabilities_id_fk",
+ "tableFrom": "payments",
+ "tableTo": "capabilities",
+ "columnsFrom": [
+ "capability_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "payments_agent_id_agents_id_fk": {
+ "name": "payments_agent_id_agents_id_fk",
+ "tableFrom": "payments",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.api_keys": {
+ "name": "api_keys",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'Default'"
+ },
+ "prefix": {
+ "name": "prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "api_keys_owner_id_idx": {
+ "name": "api_keys_owner_id_idx",
+ "columns": [
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "api_keys_key_hash_idx": {
+ "name": "api_keys_key_hash_idx",
+ "columns": [
+ {
+ "expression": "key_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "api_keys_owner_id_users_id_fk": {
+ "name": "api_keys_owner_id_users_id_fk",
+ "tableFrom": "api_keys",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "api_keys_agent_id_agents_id_fk": {
+ "name": "api_keys_agent_id_agents_id_fk",
+ "tableFrom": "api_keys",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "api_keys_key_hash_unique": {
+ "name": "api_keys_key_hash_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key_hash"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reviews": {
+ "name": "reviews",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "capability_id": {
+ "name": "capability_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reviewer_id": {
+ "name": "reviewer_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rating": {
+ "name": "rating",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "comment": {
+ "name": "comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "reviews_capability_id_idx": {
+ "name": "reviews_capability_id_idx",
+ "columns": [
+ {
+ "expression": "capability_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "reviews_capability_id_capabilities_id_fk": {
+ "name": "reviews_capability_id_capabilities_id_fk",
+ "tableFrom": "reviews",
+ "tableTo": "capabilities",
+ "columnsFrom": [
+ "capability_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reviews_reviewer_id_users_id_fk": {
+ "name": "reviews_reviewer_id_users_id_fk",
+ "tableFrom": "reviews",
+ "tableTo": "users",
+ "columnsFrom": [
+ "reviewer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "reviews_capability_reviewer_unique": {
+ "name": "reviews_capability_reviewer_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "capability_id",
+ "reviewer_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.product_actions": {
+ "name": "product_actions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "product_id": {
+ "name": "product_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "product_action_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "share_as_capability": {
+ "name": "share_as_capability",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "product_actions_product_id_idx": {
+ "name": "product_actions_product_id_idx",
+ "columns": [
+ {
+ "expression": "product_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "product_actions_product_id_products_id_fk": {
+ "name": "product_actions_product_id_products_id_fk",
+ "tableFrom": "product_actions",
+ "tableTo": "products",
+ "columnsFrom": [
+ "product_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.product_content": {
+ "name": "product_content",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "product_id": {
+ "name": "product_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "product_content_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "product_content_product_id_idx": {
+ "name": "product_content_product_id_idx",
+ "columns": [
+ {
+ "expression": "product_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "product_content_product_id_products_id_fk": {
+ "name": "product_content_product_id_products_id_fk",
+ "tableFrom": "product_content",
+ "tableTo": "products",
+ "columnsFrom": [
+ "product_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.products": {
+ "name": "products",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "logo_url": {
+ "name": "logo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "brand_color": {
+ "name": "brand_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#156DFC'"
+ },
+ "greeting": {
+ "name": "greeting",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "product_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "settings": {
+ "name": "settings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "products_owner_id_idx": {
+ "name": "products_owner_id_idx",
+ "columns": [
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "products_owner_id_users_id_fk": {
+ "name": "products_owner_id_users_id_fk",
+ "tableFrom": "products",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "products_slug_unique": {
+ "name": "products_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ },
+ "products_public_key_unique": {
+ "name": "products_public_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "public_key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chat_messages": {
+ "name": "chat_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "chat_message_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "chat_messages_thread_id_idx": {
+ "name": "chat_messages_thread_id_idx",
+ "columns": [
+ {
+ "expression": "thread_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chat_messages_thread_id_chat_threads_id_fk": {
+ "name": "chat_messages_thread_id_chat_threads_id_fk",
+ "tableFrom": "chat_messages",
+ "tableTo": "chat_threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chat_threads": {
+ "name": "chat_threads",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "chat_threads_owner_id_idx": {
+ "name": "chat_threads_owner_id_idx",
+ "columns": [
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chat_threads_owner_id_users_id_fk": {
+ "name": "chat_threads_owner_id_users_id_fk",
+ "tableFrom": "chat_threads",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.capability_kind": {
+ "name": "capability_kind",
+ "schema": "public",
+ "values": [
+ "api",
+ "mcp",
+ "agent",
+ "model",
+ "dataset",
+ "credit"
+ ]
+ },
+ "public.capability_status": {
+ "name": "capability_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "pending",
+ "verified"
+ ]
+ },
+ "public.capability_visibility": {
+ "name": "capability_visibility",
+ "schema": "public",
+ "values": [
+ "public",
+ "unlisted",
+ "private"
+ ]
+ },
+ "public.payment_status": {
+ "name": "payment_status",
+ "schema": "public",
+ "values": [
+ "pending",
+ "settled",
+ "failed",
+ "refunded"
+ ]
+ },
+ "public.product_action_kind": {
+ "name": "product_action_kind",
+ "schema": "public",
+ "values": [
+ "capability",
+ "http"
+ ]
+ },
+ "public.product_content_type": {
+ "name": "product_content_type",
+ "schema": "public",
+ "values": [
+ "doc",
+ "snippet",
+ "website",
+ "faq"
+ ]
+ },
+ "public.product_status": {
+ "name": "product_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "live"
+ ]
+ },
+ "public.chat_message_role": {
+ "name": "chat_message_role",
+ "schema": "public",
+ "values": [
+ "user",
+ "assistant"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json
index d1fd5d2..a4e824a 100644
--- a/packages/database/drizzle/meta/_journal.json
+++ b/packages/database/drizzle/meta/_journal.json
@@ -113,6 +113,13 @@
"when": 1784400000000,
"tag": "0015_chat_threads_and_messages",
"breakpoints": true
+ },
+ {
+ "idx": 16,
+ "version": "7",
+ "when": 1785490120134,
+ "tag": "0016_common_captain_cross",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts
index 0bd41a8..ed4cbbf 100644
--- a/packages/database/src/schema/index.ts
+++ b/packages/database/src/schema/index.ts
@@ -8,5 +8,6 @@ export * from "./agents";
export * from "./payments";
export * from "./api-keys";
export * from "./reviews";
+export * from "./products";
export * from "./relations";
export * from "./chat";
diff --git a/packages/database/src/schema/products.ts b/packages/database/src/schema/products.ts
new file mode 100644
index 0000000..cfa83e6
--- /dev/null
+++ b/packages/database/src/schema/products.ts
@@ -0,0 +1,108 @@
+import { boolean, index, jsonb, pgEnum, pgTable, text, uuid } from "drizzle-orm/pg-core";
+import { primaryId, timestamps } from "./_shared";
+import { users } from "./users";
+
+/** Lifecycle of a product agent: draft until the owner goes live. */
+export const productStatus = pgEnum("product_status", ["draft", "live"]);
+
+/** Kinds of content the agent can be trained on. */
+export const productContentType = pgEnum("product_content_type", [
+ "doc",
+ "snippet",
+ "website",
+ "faq",
+]);
+
+/**
+ * How a product action is invoked:
+ * - `capability` — a Tael capability slug
+ * - `http` — a direct HTTP call with url/method/paramsSchema
+ */
+export const productActionKind = pgEnum("product_action_kind", ["capability", "http"]);
+
+/**
+ * Config for a product action. Discriminated by `kind` on the row:
+ * - capability → `{ slug }`
+ * - http → `{ url, method, paramsSchema? }`
+ */
+export type ProductActionConfig =
+ { slug: string } | { url: string; method: string; paramsSchema?: Record };
+
+/**
+ * One product tenant: an embeddable agent configured by a product owner.
+ * `publicKey` is the Stripe-style publishable key used by the embed snippet.
+ */
+export const products = pgTable(
+ "products",
+ {
+ id: primaryId(),
+ ownerId: uuid("owner_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ /** URL-safe unique handle. */
+ slug: text("slug").notNull().unique(),
+ name: text("name").notNull(),
+ description: text("description").notNull().default(""),
+ /** Optional product logo URL for widget branding. */
+ logoUrl: text("logo_url"),
+ /** Widget accent color. */
+ brandColor: text("brand_color").notNull().default("#156DFC"),
+ /** First message the widget shows. */
+ greeting: text("greeting").notNull().default(""),
+ /** Embed key, safe to expose (e.g. `tael_pub_…`). */
+ publicKey: text("public_key").notNull().unique(),
+ status: productStatus("status").notNull().default("draft"),
+ /** Misc future config (channels, etc.). */
+ settings: jsonb("settings").$type>().notNull().default({}),
+ ...timestamps,
+ },
+ (table) => [index("products_owner_id_idx").on(table.ownerId)],
+);
+
+/** Content the product agent knows (Train data). */
+export const productContent = pgTable(
+ "product_content",
+ {
+ id: primaryId(),
+ productId: uuid("product_id")
+ .notNull()
+ .references(() => products.id, { onDelete: "cascade" }),
+ type: productContentType("type").notNull(),
+ title: text("title").notNull(),
+ /** The text the agent uses at chat time. */
+ body: text("body").notNull(),
+ /** Source URL for website sync. */
+ sourceUrl: text("source_url"),
+ enabled: boolean("enabled").notNull().default(true),
+ ...timestamps,
+ },
+ (table) => [index("product_content_product_id_idx").on(table.productId)],
+);
+
+/** Actions the product agent can run (connected capabilities / HTTP). */
+export const productActions = pgTable(
+ "product_actions",
+ {
+ id: primaryId(),
+ productId: uuid("product_id")
+ .notNull()
+ .references(() => products.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+ /** What it does and when the agent should use it. */
+ description: text("description").notNull(),
+ kind: productActionKind("kind").notNull(),
+ config: jsonb("config").$type().notNull(),
+ /** Opt-in: publish so other agents can call it as a capability. */
+ shareAsCapability: boolean("share_as_capability").notNull().default(false),
+ enabled: boolean("enabled").notNull().default(true),
+ ...timestamps,
+ },
+ (table) => [index("product_actions_product_id_idx").on(table.productId)],
+);
+
+export type Product = typeof products.$inferSelect;
+export type NewProduct = typeof products.$inferInsert;
+export type ProductContent = typeof productContent.$inferSelect;
+export type NewProductContent = typeof productContent.$inferInsert;
+export type ProductAction = typeof productActions.$inferSelect;
+export type NewProductAction = typeof productActions.$inferInsert;
diff --git a/packages/database/src/schema/relations.ts b/packages/database/src/schema/relations.ts
index 85babd9..ed5e7df 100644
--- a/packages/database/src/schema/relations.ts
+++ b/packages/database/src/schema/relations.ts
@@ -6,6 +6,7 @@ import { agents } from "./agents";
import { payments } from "./payments";
import { apiKeys } from "./api-keys";
import { chatThreads, chatMessages } from "./chat";
+import { products, productContent, productActions } from "./products";
// Typed relations enable Drizzle's relational query API (db.query.users.findMany
// with `with: { wallets: true }`, etc.) without hand-written joins.
@@ -15,6 +16,7 @@ export const usersRelations = relations(users, ({ many }) => ({
agents: many(agents),
capabilities: many(capabilities),
apiKeys: many(apiKeys),
+ products: many(products),
}));
export const walletsRelations = relations(wallets, ({ one, many }) => ({
@@ -50,3 +52,17 @@ export const chatThreadsRelations = relations(chatThreads, ({ one, many }) => ({
export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({
thread: one(chatThreads, { fields: [chatMessages.threadId], references: [chatThreads.id] }),
}));
+
+export const productsRelations = relations(products, ({ one, many }) => ({
+ owner: one(users, { fields: [products.ownerId], references: [users.id] }),
+ content: many(productContent),
+ actions: many(productActions),
+}));
+
+export const productContentRelations = relations(productContent, ({ one }) => ({
+ product: one(products, { fields: [productContent.productId], references: [products.id] }),
+}));
+
+export const productActionsRelations = relations(productActions, ({ one }) => ({
+ product: one(products, { fields: [productActions.productId], references: [products.id] }),
+}));