From 48c2af3bfc085788d1ffe0c45f39cfe656afc55d Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Thu, 26 Mar 2026 12:38:14 +0100
Subject: [PATCH 01/11] feat: agent plugin
---
.../client/src/routeTree.gen.ts | 21 +
.../client/src/routes/__root.tsx | 8 +
.../client/src/routes/agent.route.tsx | 434 ++++++++++++
.../client/src/routes/index.tsx | 19 +
apps/dev-playground/package.json | 2 +
apps/dev-playground/server/index.ts | 37 +-
.../docs/api/appkit/Interface.AgentAdapter.md | 20 +
docs/docs/api/appkit/Interface.AgentInput.md | 33 +
.../api/appkit/Interface.AgentRunContext.md | 28 +
.../appkit/Interface.AgentToolDefinition.md | 33 +
docs/docs/api/appkit/Interface.Message.md | 49 ++
docs/docs/api/appkit/Interface.Thread.md | 41 ++
docs/docs/api/appkit/Interface.ThreadStore.md | 98 +++
.../docs/api/appkit/Interface.ToolProvider.md | 36 +
docs/docs/api/appkit/TypeAlias.AgentEvent.md | 38 ++
docs/docs/api/appkit/index.md | 9 +
docs/docs/api/appkit/typedoc-sidebar.ts | 45 ++
packages/appkit/package.json | 31 +
packages/appkit/src/agents/databricks.ts | 632 ++++++++++++++++++
packages/appkit/src/agents/langchain.ts | 197 ++++++
.../src/agents/tests/databricks.test.ts | 406 +++++++++++
.../appkit/src/agents/tests/langchain.test.ts | 176 +++++
.../appkit/src/agents/tests/vercel-ai.test.ts | 190 ++++++
packages/appkit/src/agents/vercel-ai.ts | 129 ++++
packages/appkit/src/index.ts | 11 +-
packages/appkit/src/plugins/agent/agent.ts | 398 +++++++++++
packages/appkit/src/plugins/agent/defaults.ts | 12 +
packages/appkit/src/plugins/agent/index.ts | 3 +
.../appkit/src/plugins/agent/manifest.json | 10 +
.../src/plugins/agent/tests/agent.test.ts | 149 +++++
.../plugins/agent/tests/thread-store.test.ts | 138 ++++
.../appkit/src/plugins/agent/thread-store.ts | 59 ++
packages/appkit/src/plugins/agent/types.ts | 34 +
.../appkit/src/plugins/analytics/analytics.ts | 38 +-
packages/appkit/src/plugins/files/plugin.ts | 140 +++-
packages/appkit/src/plugins/genie/genie.ts | 93 ++-
packages/appkit/src/plugins/index.ts | 1 +
.../appkit/src/plugins/lakebase/lakebase.ts | 41 +-
packages/appkit/tsdown.config.ts | 7 +-
packages/shared/src/agent.ts | 112 ++++
packages/shared/src/index.ts | 1 +
template/appkit.plugins.json | 10 +
42 files changed, 3960 insertions(+), 9 deletions(-)
create mode 100644 apps/dev-playground/client/src/routes/agent.route.tsx
create mode 100644 docs/docs/api/appkit/Interface.AgentAdapter.md
create mode 100644 docs/docs/api/appkit/Interface.AgentInput.md
create mode 100644 docs/docs/api/appkit/Interface.AgentRunContext.md
create mode 100644 docs/docs/api/appkit/Interface.AgentToolDefinition.md
create mode 100644 docs/docs/api/appkit/Interface.Message.md
create mode 100644 docs/docs/api/appkit/Interface.Thread.md
create mode 100644 docs/docs/api/appkit/Interface.ThreadStore.md
create mode 100644 docs/docs/api/appkit/Interface.ToolProvider.md
create mode 100644 docs/docs/api/appkit/TypeAlias.AgentEvent.md
create mode 100644 packages/appkit/src/agents/databricks.ts
create mode 100644 packages/appkit/src/agents/langchain.ts
create mode 100644 packages/appkit/src/agents/tests/databricks.test.ts
create mode 100644 packages/appkit/src/agents/tests/langchain.test.ts
create mode 100644 packages/appkit/src/agents/tests/vercel-ai.test.ts
create mode 100644 packages/appkit/src/agents/vercel-ai.ts
create mode 100644 packages/appkit/src/plugins/agent/agent.ts
create mode 100644 packages/appkit/src/plugins/agent/defaults.ts
create mode 100644 packages/appkit/src/plugins/agent/index.ts
create mode 100644 packages/appkit/src/plugins/agent/manifest.json
create mode 100644 packages/appkit/src/plugins/agent/tests/agent.test.ts
create mode 100644 packages/appkit/src/plugins/agent/tests/thread-store.test.ts
create mode 100644 packages/appkit/src/plugins/agent/thread-store.ts
create mode 100644 packages/appkit/src/plugins/agent/types.ts
create mode 100644 packages/shared/src/agent.ts
diff --git a/apps/dev-playground/client/src/routeTree.gen.ts b/apps/dev-playground/client/src/routeTree.gen.ts
index c4c38d14..c3b807f5 100644
--- a/apps/dev-playground/client/src/routeTree.gen.ts
+++ b/apps/dev-playground/client/src/routeTree.gen.ts
@@ -20,6 +20,7 @@ import { Route as DataVisualizationRouteRouteImport } from './routes/data-visual
import { Route as ChartInferenceRouteRouteImport } from './routes/chart-inference.route'
import { Route as ArrowAnalyticsRouteRouteImport } from './routes/arrow-analytics.route'
import { Route as AnalyticsRouteRouteImport } from './routes/analytics.route'
+import { Route as AgentRouteRouteImport } from './routes/agent.route'
import { Route as IndexRouteImport } from './routes/index'
const TypeSafetyRouteRoute = TypeSafetyRouteRouteImport.update({
@@ -77,6 +78,11 @@ const AnalyticsRouteRoute = AnalyticsRouteRouteImport.update({
path: '/analytics',
getParentRoute: () => rootRouteImport,
} as any)
+const AgentRouteRoute = AgentRouteRouteImport.update({
+ id: '/agent',
+ path: '/agent',
+ getParentRoute: () => rootRouteImport,
+} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -85,6 +91,7 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/agent': typeof AgentRouteRoute
'/analytics': typeof AnalyticsRouteRoute
'/arrow-analytics': typeof ArrowAnalyticsRouteRoute
'/chart-inference': typeof ChartInferenceRouteRoute
@@ -99,6 +106,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/agent': typeof AgentRouteRoute
'/analytics': typeof AnalyticsRouteRoute
'/arrow-analytics': typeof ArrowAnalyticsRouteRoute
'/chart-inference': typeof ChartInferenceRouteRoute
@@ -114,6 +122,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/agent': typeof AgentRouteRoute
'/analytics': typeof AnalyticsRouteRoute
'/arrow-analytics': typeof ArrowAnalyticsRouteRoute
'/chart-inference': typeof ChartInferenceRouteRoute
@@ -130,6 +139,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
+ | '/agent'
| '/analytics'
| '/arrow-analytics'
| '/chart-inference'
@@ -144,6 +154,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
+ | '/agent'
| '/analytics'
| '/arrow-analytics'
| '/chart-inference'
@@ -158,6 +169,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
+ | '/agent'
| '/analytics'
| '/arrow-analytics'
| '/chart-inference'
@@ -173,6 +185,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ AgentRouteRoute: typeof AgentRouteRoute
AnalyticsRouteRoute: typeof AnalyticsRouteRoute
ArrowAnalyticsRouteRoute: typeof ArrowAnalyticsRouteRoute
ChartInferenceRouteRoute: typeof ChartInferenceRouteRoute
@@ -265,6 +278,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AnalyticsRouteRouteImport
parentRoute: typeof rootRouteImport
}
+ '/agent': {
+ id: '/agent'
+ path: '/agent'
+ fullPath: '/agent'
+ preLoaderRoute: typeof AgentRouteRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/': {
id: '/'
path: '/'
@@ -277,6 +297,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ AgentRouteRoute: AgentRouteRoute,
AnalyticsRouteRoute: AnalyticsRouteRoute,
ArrowAnalyticsRouteRoute: ArrowAnalyticsRouteRoute,
ChartInferenceRouteRoute: ChartInferenceRouteRoute,
diff --git a/apps/dev-playground/client/src/routes/__root.tsx b/apps/dev-playground/client/src/routes/__root.tsx
index 5cf74ce3..0cfee693 100644
--- a/apps/dev-playground/client/src/routes/__root.tsx
+++ b/apps/dev-playground/client/src/routes/__root.tsx
@@ -104,6 +104,14 @@ function RootComponent() {
Files
+
+
+
diff --git a/apps/dev-playground/client/src/routes/agent.route.tsx b/apps/dev-playground/client/src/routes/agent.route.tsx
new file mode 100644
index 00000000..cdebfc54
--- /dev/null
+++ b/apps/dev-playground/client/src/routes/agent.route.tsx
@@ -0,0 +1,434 @@
+import { Button } from "@databricks/appkit-ui/react";
+import { createFileRoute } from "@tanstack/react-router";
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export const Route = createFileRoute("/agent")({
+ component: AgentRoute,
+});
+
+interface AgentEvent {
+ type: string;
+ content?: string;
+ callId?: string;
+ name?: string;
+ args?: unknown;
+ result?: unknown;
+ error?: string;
+ status?: string;
+ data?: Record;
+}
+
+interface ChatMessage {
+ id: number;
+ role: "user" | "assistant";
+ content: string;
+}
+
+function useAutocomplete(enabled: boolean) {
+ const [suggestion, setSuggestion] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const abortRef = useRef(null);
+ const timerRef = useRef | null>(null);
+
+ const requestSuggestion = useCallback(
+ (text: string) => {
+ setSuggestion("");
+
+ if (timerRef.current) clearTimeout(timerRef.current);
+ if (abortRef.current) abortRef.current.abort();
+
+ if (!text.trim() || text.length < 3 || !enabled) {
+ return;
+ }
+
+ timerRef.current = setTimeout(async () => {
+ const controller = new AbortController();
+ abortRef.current = controller;
+ setIsLoading(true);
+
+ try {
+ const response = await fetch("/api/agent/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: text, agent: "autocomplete" }),
+ signal: controller.signal,
+ });
+
+ if (!response.ok || !response.body) return;
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let result = "";
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() ?? "";
+
+ for (const line of lines) {
+ if (!line.startsWith("data: ")) continue;
+ const data = line.slice(6).trim();
+ if (!data || data === "[DONE]") continue;
+ try {
+ const event = JSON.parse(data);
+ if (event.type === "message_delta" && event.content) {
+ result += event.content;
+ setSuggestion(result);
+ }
+ } catch {
+ /* skip */
+ }
+ }
+ }
+ } catch {
+ /* aborted or failed */
+ } finally {
+ setIsLoading(false);
+ }
+ }, 500);
+ },
+ [enabled],
+ );
+
+ const clear = useCallback(() => {
+ setSuggestion("");
+ if (timerRef.current) clearTimeout(timerRef.current);
+ if (abortRef.current) abortRef.current.abort();
+ }, []);
+
+ return {
+ suggestion,
+ isLoading: isLoading && !suggestion,
+ requestSuggestion,
+ clear,
+ };
+}
+
+function AgentRoute() {
+ const [messages, setMessages] = useState([]);
+ const [events, setEvents] = useState([]);
+ const [input, setInput] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const [threadId, setThreadId] = useState(null);
+ const [hasAutocomplete, setHasAutocomplete] = useState(false);
+ const messagesEndRef = useRef(null);
+ const inputRef = useRef(null);
+ const msgIdCounter = useRef(0);
+
+ const {
+ suggestion,
+ isLoading: isAutocompleting,
+ requestSuggestion,
+ clear: clearSuggestion,
+ } = useAutocomplete(hasAutocomplete);
+
+ useEffect(() => {
+ fetch("/api/agent/agents")
+ .then((r) => r.json())
+ .then((data) => {
+ setHasAutocomplete((data.agents ?? []).includes("autocomplete"));
+ })
+ .catch(() => {});
+ }, []);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [messages]);
+
+ const sendMessage = useCallback(async () => {
+ if (!input.trim() || isLoading) return;
+
+ clearSuggestion();
+ const userMessage = input.trim();
+ setInput("");
+ setMessages((prev) => [
+ ...prev,
+ { id: ++msgIdCounter.current, role: "user", content: userMessage },
+ ]);
+ setEvents([]);
+ setIsLoading(true);
+
+ try {
+ const response = await fetch("/api/agent/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: userMessage,
+ ...(threadId && { threadId }),
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: ++msgIdCounter.current,
+ role: "assistant",
+ content: `Error: ${error.error}`,
+ },
+ ]);
+ return;
+ }
+
+ const reader = response.body?.getReader();
+ if (!reader) return;
+
+ const decoder = new TextDecoder();
+ let assistantContent = "";
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() ?? "";
+
+ for (const line of lines) {
+ if (!line.startsWith("data: ")) continue;
+ const data = line.slice(6).trim();
+ if (!data || data === "[DONE]") continue;
+
+ try {
+ const event: AgentEvent = JSON.parse(data);
+ setEvents((prev) => [...prev, event]);
+
+ if (event.type === "metadata" && event.data?.threadId) {
+ setThreadId(event.data.threadId as string);
+ }
+
+ if (event.type === "message_delta" && event.content) {
+ assistantContent += event.content;
+ setMessages((prev) => {
+ const updated = [...prev];
+ const last = updated[updated.length - 1];
+ if (last?.role === "assistant") {
+ updated[updated.length - 1] = {
+ ...last,
+ content: assistantContent,
+ };
+ } else {
+ updated.push({
+ id: ++msgIdCounter.current,
+ role: "assistant",
+ content: assistantContent,
+ });
+ }
+ return updated;
+ });
+ }
+ } catch {
+ // skip malformed events
+ }
+ }
+ }
+ } catch (err) {
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: ++msgIdCounter.current,
+ role: "assistant",
+ content: `Error: ${err instanceof Error ? err.message : "Unknown error"}`,
+ },
+ ]);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [input, isLoading, threadId, clearSuggestion]);
+
+ const handleInputChange = (value: string) => {
+ setInput(value);
+ requestSuggestion(value);
+ };
+
+ const acceptSuggestion = () => {
+ if (!suggestion) return;
+ const newValue = input + suggestion;
+ setInput(newValue);
+ clearSuggestion();
+ inputRef.current?.focus();
+ };
+
+ return (
+
+
+
+
+
Agent Chat
+
+ AI agent with auto-discovered tools from all AppKit plugins.
+ {threadId && (
+
+ Thread: {threadId.slice(0, 8)}...
+
+ )}
+
+
+ {hasAutocomplete && (
+
+ Autocomplete enabled
+
+ )}
+
+
+
+
+
+ {messages.length === 0 && (
+
+
+ Send a message to start a conversation
+
+
+ The agent can use analytics, files, genie, and lakebase
+ tools.
+ {hasAutocomplete && " Start typing for inline suggestions."}
+
+
+ )}
+
+ {messages.map((msg) => (
+
+ ))}
+
+ {isLoading && messages[messages.length - 1]?.role === "user" && (
+
+ )}
+
+
+
+
+
+ {hasAutocomplete && (suggestion || isAutocompleting) && (
+
+ {isAutocompleting && (
+ Thinking...
+ )}
+ {suggestion && (
+
+ Press{" "}
+
+ Tab
+ {" "}
+ to accept suggestion
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+ Event Stream
+
+
+
+ {events.length === 0 && (
+
+ Events will appear here
+
+ )}
+ {events.map((event, i) => (
+
+
+ {event.type}
+
+
+ {event.type === "message_delta"
+ ? event.content?.slice(0, 60)
+ : event.type === "tool_call"
+ ? `${event.name}(${JSON.stringify(event.args).slice(0, 40)})`
+ : event.type === "tool_result"
+ ? `${String(event.result).slice(0, 60)}`
+ : event.type === "status"
+ ? event.status
+ : JSON.stringify(event).slice(0, 60)}
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/apps/dev-playground/client/src/routes/index.tsx b/apps/dev-playground/client/src/routes/index.tsx
index e331d93c..896a6e9d 100644
--- a/apps/dev-playground/client/src/routes/index.tsx
+++ b/apps/dev-playground/client/src/routes/index.tsx
@@ -218,6 +218,25 @@ function IndexRoute() {
+
+
+
+
+ Custom Agent
+
+
+ AI agent powered by Databricks Model Serving with
+ auto-discovered tools from all AppKit plugins. Chat with your
+ data using natural language.
+
+
+
+
diff --git a/apps/dev-playground/package.json b/apps/dev-playground/package.json
index d7558cee..59d2feb7 100644
--- a/apps/dev-playground/package.json
+++ b/apps/dev-playground/package.json
@@ -36,6 +36,8 @@
"dotenv": "16.6.1",
"tsdown": "0.20.3",
"tsx": "4.20.6",
+ "@ai-sdk/openai": "1.0.0",
+ "ai": "4.0.0",
"vite": "npm:rolldown-vite@7.1.14"
},
"overrides": {
diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts
index a4b6a2c6..88bb81e9 100644
--- a/apps/dev-playground/server/index.ts
+++ b/apps/dev-playground/server/index.ts
@@ -1,5 +1,13 @@
import "reflect-metadata";
-import { analytics, createApp, files, genie, server } from "@databricks/appkit";
+import {
+ agent,
+ analytics,
+ createApp,
+ files,
+ genie,
+ server,
+} from "@databricks/appkit";
+import { DatabricksAdapter } from "@databricks/appkit/agents/databricks";
import { WorkspaceClient } from "@databricks/sdk-experimental";
import { lakebaseExamples } from "./lakebase-examples-plugin";
import { reconnect } from "./reconnect-plugin";
@@ -15,6 +23,10 @@ function createMockClient() {
return client;
}
+const wsClient = new WorkspaceClient({});
+const endpointName =
+ process.env.DATABRICKS_AGENT_ENDPOINT ?? "databricks-claude-sonnet-4-5";
+
createApp({
plugins: [
server({ autoStart: false }),
@@ -26,6 +38,29 @@ createApp({
}),
lakebaseExamples(),
files(),
+ agent({
+ agents: {
+ assistant: DatabricksAdapter.fromServingEndpoint({
+ workspaceClient: wsClient,
+ endpointName,
+ systemPrompt:
+ "You are a helpful data assistant. Use the available tools to query data and help users with their analysis.",
+ }),
+ autocomplete: DatabricksAdapter.fromServingEndpoint({
+ workspaceClient: wsClient,
+ endpointName: "databricks-gemini-3-1-flash-lite",
+ systemPrompt: [
+ "You are an autocomplete engine.",
+ "The user will give you the beginning of a sentence or paragraph.",
+ "Continue the text naturally, as if you are the same author.",
+ "Do NOT repeat the input. Only output the continuation.",
+ "Do NOT use tools. Do NOT explain. Just write the next words.",
+ ].join(" "),
+ maxSteps: 1,
+ }),
+ },
+ defaultAgent: "assistant",
+ }),
],
...(process.env.APPKIT_E2E_TEST && { client: createMockClient() }),
}).then((appkit) => {
diff --git a/docs/docs/api/appkit/Interface.AgentAdapter.md b/docs/docs/api/appkit/Interface.AgentAdapter.md
new file mode 100644
index 00000000..52083157
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.AgentAdapter.md
@@ -0,0 +1,20 @@
+# Interface: AgentAdapter
+
+## Methods
+
+### run()
+
+```ts
+run(input: AgentInput, context: AgentRunContext): AsyncGenerator
;
+```
+
+#### Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `input` | [`AgentInput`](Interface.AgentInput.md) |
+| `context` | [`AgentRunContext`](Interface.AgentRunContext.md) |
+
+#### Returns
+
+`AsyncGenerator`\<[`AgentEvent`](TypeAlias.AgentEvent.md), `void`, `unknown`\>
diff --git a/docs/docs/api/appkit/Interface.AgentInput.md b/docs/docs/api/appkit/Interface.AgentInput.md
new file mode 100644
index 00000000..6d2eff8b
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.AgentInput.md
@@ -0,0 +1,33 @@
+# Interface: AgentInput
+
+## Properties
+
+### messages
+
+```ts
+messages: Message[];
+```
+
+***
+
+### signal?
+
+```ts
+optional signal: AbortSignal;
+```
+
+***
+
+### threadId
+
+```ts
+threadId: string;
+```
+
+***
+
+### tools
+
+```ts
+tools: AgentToolDefinition[];
+```
diff --git a/docs/docs/api/appkit/Interface.AgentRunContext.md b/docs/docs/api/appkit/Interface.AgentRunContext.md
new file mode 100644
index 00000000..c9bfcb79
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.AgentRunContext.md
@@ -0,0 +1,28 @@
+# Interface: AgentRunContext
+
+## Properties
+
+### executeTool()
+
+```ts
+executeTool: (name: string, args: unknown) => Promise;
+```
+
+#### Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `name` | `string` |
+| `args` | `unknown` |
+
+#### Returns
+
+`Promise`\<`unknown`\>
+
+***
+
+### signal?
+
+```ts
+optional signal: AbortSignal;
+```
diff --git a/docs/docs/api/appkit/Interface.AgentToolDefinition.md b/docs/docs/api/appkit/Interface.AgentToolDefinition.md
new file mode 100644
index 00000000..51c37595
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.AgentToolDefinition.md
@@ -0,0 +1,33 @@
+# Interface: AgentToolDefinition
+
+## Properties
+
+### annotations?
+
+```ts
+optional annotations: ToolAnnotations;
+```
+
+***
+
+### description
+
+```ts
+description: string;
+```
+
+***
+
+### name
+
+```ts
+name: string;
+```
+
+***
+
+### parameters
+
+```ts
+parameters: JSONSchema7;
+```
diff --git a/docs/docs/api/appkit/Interface.Message.md b/docs/docs/api/appkit/Interface.Message.md
new file mode 100644
index 00000000..ed818408
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.Message.md
@@ -0,0 +1,49 @@
+# Interface: Message
+
+## Properties
+
+### content
+
+```ts
+content: string;
+```
+
+***
+
+### createdAt
+
+```ts
+createdAt: Date;
+```
+
+***
+
+### id
+
+```ts
+id: string;
+```
+
+***
+
+### role
+
+```ts
+role: "user" | "assistant" | "system" | "tool";
+```
+
+***
+
+### toolCallId?
+
+```ts
+optional toolCallId: string;
+```
+
+***
+
+### toolCalls?
+
+```ts
+optional toolCalls: ToolCall[];
+```
diff --git a/docs/docs/api/appkit/Interface.Thread.md b/docs/docs/api/appkit/Interface.Thread.md
new file mode 100644
index 00000000..e9f15fee
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.Thread.md
@@ -0,0 +1,41 @@
+# Interface: Thread
+
+## Properties
+
+### createdAt
+
+```ts
+createdAt: Date;
+```
+
+***
+
+### id
+
+```ts
+id: string;
+```
+
+***
+
+### messages
+
+```ts
+messages: Message[];
+```
+
+***
+
+### updatedAt
+
+```ts
+updatedAt: Date;
+```
+
+***
+
+### userId
+
+```ts
+userId: string;
+```
diff --git a/docs/docs/api/appkit/Interface.ThreadStore.md b/docs/docs/api/appkit/Interface.ThreadStore.md
new file mode 100644
index 00000000..215b76a2
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.ThreadStore.md
@@ -0,0 +1,98 @@
+# Interface: ThreadStore
+
+## Methods
+
+### addMessage()
+
+```ts
+addMessage(
+ threadId: string,
+ userId: string,
+message: Message): Promise;
+```
+
+#### Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `threadId` | `string` |
+| `userId` | `string` |
+| `message` | [`Message`](Interface.Message.md) |
+
+#### Returns
+
+`Promise`\<`void`\>
+
+***
+
+### create()
+
+```ts
+create(userId: string): Promise;
+```
+
+#### Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `userId` | `string` |
+
+#### Returns
+
+`Promise`\<[`Thread`](Interface.Thread.md)\>
+
+***
+
+### delete()
+
+```ts
+delete(threadId: string, userId: string): Promise;
+```
+
+#### Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `threadId` | `string` |
+| `userId` | `string` |
+
+#### Returns
+
+`Promise`\<`boolean`\>
+
+***
+
+### get()
+
+```ts
+get(threadId: string, userId: string): Promise;
+```
+
+#### Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `threadId` | `string` |
+| `userId` | `string` |
+
+#### Returns
+
+`Promise`\<[`Thread`](Interface.Thread.md) \| `null`\>
+
+***
+
+### list()
+
+```ts
+list(userId: string): Promise;
+```
+
+#### Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `userId` | `string` |
+
+#### Returns
+
+`Promise`\<[`Thread`](Interface.Thread.md)[]\>
diff --git a/docs/docs/api/appkit/Interface.ToolProvider.md b/docs/docs/api/appkit/Interface.ToolProvider.md
new file mode 100644
index 00000000..9c8851a0
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.ToolProvider.md
@@ -0,0 +1,36 @@
+# Interface: ToolProvider
+
+## Methods
+
+### executeAgentTool()
+
+```ts
+executeAgentTool(
+ name: string,
+ args: unknown,
+signal?: AbortSignal): Promise;
+```
+
+#### Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `name` | `string` |
+| `args` | `unknown` |
+| `signal?` | `AbortSignal` |
+
+#### Returns
+
+`Promise`\<`unknown`\>
+
+***
+
+### getAgentTools()
+
+```ts
+getAgentTools(): AgentToolDefinition[];
+```
+
+#### Returns
+
+[`AgentToolDefinition`](Interface.AgentToolDefinition.md)[]
diff --git a/docs/docs/api/appkit/TypeAlias.AgentEvent.md b/docs/docs/api/appkit/TypeAlias.AgentEvent.md
new file mode 100644
index 00000000..7c7cd92c
--- /dev/null
+++ b/docs/docs/api/appkit/TypeAlias.AgentEvent.md
@@ -0,0 +1,38 @@
+# Type Alias: AgentEvent
+
+```ts
+type AgentEvent =
+ | {
+ content: string;
+ type: "message_delta";
+}
+ | {
+ content: string;
+ type: "message";
+}
+ | {
+ args: unknown;
+ callId: string;
+ name: string;
+ type: "tool_call";
+}
+ | {
+ callId: string;
+ error?: string;
+ result: unknown;
+ type: "tool_result";
+}
+ | {
+ content: string;
+ type: "thinking";
+}
+ | {
+ error?: string;
+ status: "running" | "waiting" | "complete" | "error";
+ type: "status";
+}
+ | {
+ data: Record;
+ type: "metadata";
+};
+```
diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md
index b5fb7ce0..5064713d 100644
--- a/docs/docs/api/appkit/index.md
+++ b/docs/docs/api/appkit/index.md
@@ -30,12 +30,17 @@ plugin architecture, and React integration.
| Interface | Description |
| ------ | ------ |
+| [AgentAdapter](Interface.AgentAdapter.md) | - |
+| [AgentInput](Interface.AgentInput.md) | - |
+| [AgentRunContext](Interface.AgentRunContext.md) | - |
+| [AgentToolDefinition](Interface.AgentToolDefinition.md) | - |
| [BasePluginConfig](Interface.BasePluginConfig.md) | Base configuration interface for AppKit plugins |
| [CacheConfig](Interface.CacheConfig.md) | Configuration for the CacheInterceptor. Controls TTL, size limits, storage backend, and probabilistic cleanup. |
| [DatabaseCredential](Interface.DatabaseCredential.md) | Database credentials with OAuth token for Postgres connection |
| [GenerateDatabaseCredentialRequest](Interface.GenerateDatabaseCredentialRequest.md) | Request parameters for generating database OAuth credentials |
| [ITelemetry](Interface.ITelemetry.md) | Plugin-facing interface for OpenTelemetry instrumentation. Provides a thin abstraction over OpenTelemetry APIs for plugins. |
| [LakebasePoolConfig](Interface.LakebasePoolConfig.md) | Configuration for creating a Lakebase connection pool |
+| [Message](Interface.Message.md) | - |
| [PluginManifest](Interface.PluginManifest.md) | Plugin manifest that declares metadata and resource requirements. Attached to plugin classes as a static property. Extends the shared PluginManifest with strict resource types. |
| [RequestedClaims](Interface.RequestedClaims.md) | Optional claims for fine-grained Unity Catalog table permissions When specified, the returned token will be scoped to only the requested tables |
| [RequestedResource](Interface.RequestedResource.md) | Resource to request permissions for in Unity Catalog |
@@ -44,12 +49,16 @@ plugin architecture, and React integration.
| [ResourceRequirement](Interface.ResourceRequirement.md) | Declares a resource requirement for a plugin. Can be defined statically in a manifest or dynamically via getResourceRequirements(). Narrows the generated base: type → ResourceType enum, permission → ResourcePermission union. |
| [StreamExecutionSettings](Interface.StreamExecutionSettings.md) | Execution settings for streaming endpoints. Extends PluginExecutionSettings with SSE stream configuration. |
| [TelemetryConfig](Interface.TelemetryConfig.md) | OpenTelemetry configuration for AppKit applications |
+| [Thread](Interface.Thread.md) | - |
+| [ThreadStore](Interface.ThreadStore.md) | - |
+| [ToolProvider](Interface.ToolProvider.md) | - |
| [ValidationResult](Interface.ValidationResult.md) | Result of validating all registered resources against the environment. |
## Type Aliases
| Type Alias | Description |
| ------ | ------ |
+| [AgentEvent](TypeAlias.AgentEvent.md) | - |
| [ConfigSchema](TypeAlias.ConfigSchema.md) | Configuration schema definition for plugin config. Re-exported from the standard JSON Schema Draft 7 types. |
| [IAppRouter](TypeAlias.IAppRouter.md) | Express router type for plugin route registration |
| [PluginData](TypeAlias.PluginData.md) | Tuple of plugin class, config, and name. Created by `toPlugin()` and passed to `createApp()`. |
diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts
index 2f17b1d2..cf28729b 100644
--- a/docs/docs/api/appkit/typedoc-sidebar.ts
+++ b/docs/docs/api/appkit/typedoc-sidebar.ts
@@ -82,6 +82,26 @@ const typedocSidebar: SidebarsConfig = {
type: "category",
label: "Interfaces",
items: [
+ {
+ type: "doc",
+ id: "api/appkit/Interface.AgentAdapter",
+ label: "AgentAdapter"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Interface.AgentInput",
+ label: "AgentInput"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Interface.AgentRunContext",
+ label: "AgentRunContext"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Interface.AgentToolDefinition",
+ label: "AgentToolDefinition"
+ },
{
type: "doc",
id: "api/appkit/Interface.BasePluginConfig",
@@ -112,6 +132,11 @@ const typedocSidebar: SidebarsConfig = {
id: "api/appkit/Interface.LakebasePoolConfig",
label: "LakebasePoolConfig"
},
+ {
+ type: "doc",
+ id: "api/appkit/Interface.Message",
+ label: "Message"
+ },
{
type: "doc",
id: "api/appkit/Interface.PluginManifest",
@@ -152,6 +177,21 @@ const typedocSidebar: SidebarsConfig = {
id: "api/appkit/Interface.TelemetryConfig",
label: "TelemetryConfig"
},
+ {
+ type: "doc",
+ id: "api/appkit/Interface.Thread",
+ label: "Thread"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Interface.ThreadStore",
+ label: "ThreadStore"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Interface.ToolProvider",
+ label: "ToolProvider"
+ },
{
type: "doc",
id: "api/appkit/Interface.ValidationResult",
@@ -163,6 +203,11 @@ const typedocSidebar: SidebarsConfig = {
type: "category",
label: "Type Aliases",
items: [
+ {
+ type: "doc",
+ id: "api/appkit/TypeAlias.AgentEvent",
+ label: "AgentEvent"
+ },
{
type: "doc",
id: "api/appkit/TypeAlias.ConfigSchema",
diff --git a/packages/appkit/package.json b/packages/appkit/package.json
index 471e168d..a72657fa 100644
--- a/packages/appkit/package.json
+++ b/packages/appkit/package.json
@@ -28,6 +28,18 @@
"development": "./src/index.ts",
"default": "./dist/index.js"
},
+ "./agents/vercel-ai": {
+ "development": "./src/agents/vercel-ai.ts",
+ "default": "./dist/agents/vercel-ai.js"
+ },
+ "./agents/langchain": {
+ "development": "./src/agents/langchain.ts",
+ "default": "./dist/agents/langchain.js"
+ },
+ "./agents/databricks": {
+ "development": "./src/agents/databricks.ts",
+ "default": "./dist/agents/databricks.js"
+ },
"./type-generator": {
"types": "./dist/type-generator/index.d.ts",
"development": "./src/type-generator/index.ts",
@@ -77,6 +89,22 @@
"vite": "npm:rolldown-vite@7.1.14",
"ws": "8.18.3"
},
+ "peerDependencies": {
+ "ai": ">=4.0.0",
+ "@langchain/core": ">=0.3.0",
+ "zod": ">=3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ai": {
+ "optional": true
+ },
+ "@langchain/core": {
+ "optional": true
+ },
+ "zod": {
+ "optional": true
+ }
+ },
"devDependencies": {
"@types/express": "4.17.25",
"@types/json-schema": "7.0.15",
@@ -91,6 +119,9 @@
"publishConfig": {
"exports": {
".": "./dist/index.js",
+ "./agents/vercel-ai": "./dist/agents/vercel-ai.js",
+ "./agents/langchain": "./dist/agents/langchain.js",
+ "./agents/databricks": "./dist/agents/databricks.js",
"./dist/shared/src/plugin": "./dist/shared/src/plugin.d.ts",
"./type-generator": "./dist/type-generator/index.js",
"./package.json": "./package.json"
diff --git a/packages/appkit/src/agents/databricks.ts b/packages/appkit/src/agents/databricks.ts
new file mode 100644
index 00000000..cf8229a7
--- /dev/null
+++ b/packages/appkit/src/agents/databricks.ts
@@ -0,0 +1,632 @@
+import type {
+ AgentAdapter,
+ AgentEvent,
+ AgentInput,
+ AgentRunContext,
+ AgentToolDefinition,
+} from "shared";
+
+interface DatabricksAdapterOptions {
+ endpointUrl: string;
+ authenticate: () => Promise>;
+ maxSteps?: number;
+ systemPrompt?: string;
+ maxTokens?: number;
+}
+
+interface WorkspaceConfig {
+ host?: string;
+ authenticate(headers: Headers): Promise;
+ ensureResolved(): Promise;
+}
+
+interface ServingEndpointOptions {
+ workspaceClient: { config: WorkspaceConfig };
+ endpointName: string;
+ maxSteps?: number;
+ systemPrompt?: string;
+ maxTokens?: number;
+}
+
+interface OpenAIMessage {
+ role: "system" | "user" | "assistant" | "tool";
+ content: string | null;
+ tool_calls?: OpenAIToolCall[];
+ tool_call_id?: string;
+}
+
+interface OpenAIToolCall {
+ id: string;
+ type: "function";
+ function: { name: string; arguments: string };
+}
+
+interface OpenAITool {
+ type: "function";
+ function: {
+ name: string;
+ description: string;
+ parameters: unknown;
+ };
+}
+
+interface DeltaToolCall {
+ index: number;
+ id?: string;
+ type?: string;
+ function?: { name?: string; arguments?: string };
+}
+
+/**
+ * Adapter that talks directly to Databricks Model Serving `/invocations` endpoint.
+ *
+ * No dependency on the Vercel AI SDK or LangChain. Uses raw `fetch()` to POST
+ * OpenAI-compatible payloads and parses the SSE stream itself. Calls
+ * `authenticate()` per-request so tokens are always fresh.
+ *
+ * Handles both structured `tool_calls` responses and text-based tool call
+ * fallback parsing for models that output tool calls as text.
+ *
+ * @example Using the factory (recommended)
+ * ```ts
+ * import { DatabricksAdapter } from "@databricks/appkit/agents/databricks";
+ * import { WorkspaceClient } from "@databricks/sdk-experimental";
+ *
+ * const adapter = DatabricksAdapter.fromServingEndpoint({
+ * workspaceClient: new WorkspaceClient({}),
+ * endpointName: "my-endpoint",
+ * });
+ * appkit.agent.registerAgent("assistant", adapter);
+ * ```
+ *
+ * @example Using the raw constructor
+ * ```ts
+ * const adapter = new DatabricksAdapter({
+ * endpointUrl: "https://host/serving-endpoints/my-endpoint/invocations",
+ * authenticate: async () => ({ Authorization: `Bearer ${token}` }),
+ * });
+ * ```
+ */
+export class DatabricksAdapter implements AgentAdapter {
+ private url: string;
+ private authenticate: () => Promise>;
+ private maxSteps: number;
+ private systemPrompt?: string;
+ private maxTokens: number;
+
+ constructor(options: DatabricksAdapterOptions) {
+ this.url = options.endpointUrl;
+ this.authenticate = options.authenticate;
+ this.maxSteps = options.maxSteps ?? 10;
+ this.systemPrompt = options.systemPrompt;
+ this.maxTokens = options.maxTokens ?? 4096;
+ }
+
+ /**
+ * Creates a DatabricksAdapter from a WorkspaceClient and endpoint name.
+ * Resolves the config once to get the host, then authenticates per-request.
+ */
+ static async fromServingEndpoint(
+ options: ServingEndpointOptions,
+ ): Promise {
+ const { workspaceClient, endpointName, ...rest } = options;
+ const config = workspaceClient.config;
+
+ await config.ensureResolved();
+
+ return new DatabricksAdapter({
+ endpointUrl: `${config.host}/serving-endpoints/${endpointName}/invocations`,
+ authenticate: async () => {
+ const headers = new Headers();
+ await config.authenticate(headers);
+ return Object.fromEntries(headers.entries());
+ },
+ ...rest,
+ });
+ }
+
+ async *run(
+ input: AgentInput,
+ context: AgentRunContext,
+ ): AsyncGenerator {
+ // Databricks API requires tool names to match [a-zA-Z0-9_-].
+ // Our tool names use dots (e.g. "analytics.query"), so we swap dots
+ // for double-underscores in the wire format and map back on receipt.
+ const nameToWire = new Map();
+ const wireToName = new Map();
+ for (const tool of input.tools) {
+ const wire = tool.name.replace(/\./g, "__");
+ nameToWire.set(tool.name, wire);
+ wireToName.set(wire, tool.name);
+ }
+
+ const tools = this.buildTools(input.tools, nameToWire);
+ const messages = this.buildMessages(input.messages);
+
+ if (this.systemPrompt) {
+ messages.unshift({ role: "system", content: this.systemPrompt });
+ }
+
+ yield { type: "status", status: "running" };
+
+ for (let step = 0; step < this.maxSteps; step++) {
+ if (context.signal?.aborted) break;
+
+ const { text, toolCalls } = yield* this.streamCompletion(
+ messages,
+ tools,
+ context,
+ );
+
+ if (toolCalls.length === 0) {
+ const parsed = parseTextToolCalls(text);
+ if (parsed.length > 0) {
+ yield* this.executeToolCalls(parsed, messages, context);
+ continue;
+ }
+ break;
+ }
+
+ messages.push({
+ role: "assistant",
+ content: text || null,
+ tool_calls: toolCalls,
+ });
+
+ for (const tc of toolCalls) {
+ const wireName = tc.function.name;
+ const originalName = wireToName.get(wireName) ?? wireName;
+ let args: unknown;
+ try {
+ args = JSON.parse(tc.function.arguments);
+ } catch {
+ args = {};
+ }
+
+ yield { type: "tool_call", callId: tc.id, name: originalName, args };
+
+ try {
+ const result = await context.executeTool(originalName, args);
+ const resultStr =
+ typeof result === "string" ? result : JSON.stringify(result);
+
+ yield { type: "tool_result", callId: tc.id, result };
+
+ messages.push({
+ role: "tool",
+ content: resultStr,
+ tool_call_id: tc.id,
+ });
+ } catch (error) {
+ const errMsg =
+ error instanceof Error ? error.message : "Tool execution failed";
+
+ yield {
+ type: "tool_result",
+ callId: tc.id,
+ result: null,
+ error: errMsg,
+ };
+
+ messages.push({
+ role: "tool",
+ content: JSON.stringify({ error: errMsg }),
+ tool_call_id: tc.id,
+ });
+ }
+ }
+ }
+ }
+
+ private async *streamCompletion(
+ messages: OpenAIMessage[],
+ tools: OpenAITool[],
+ context: AgentRunContext,
+ ): AsyncGenerator<
+ AgentEvent,
+ { text: string; toolCalls: OpenAIToolCall[] },
+ unknown
+ > {
+ const body: Record = {
+ messages,
+ stream: true,
+ max_tokens: this.maxTokens,
+ };
+
+ if (tools.length > 0) {
+ body.tools = tools;
+ }
+
+ const authHeaders = await this.authenticate();
+
+ const response = await fetch(this.url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...authHeaders,
+ },
+ body: JSON.stringify(body),
+ signal: context.signal,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => "Unknown error");
+ throw new Error(
+ `Databricks API error (${response.status}): ${errorText}`,
+ );
+ }
+
+ const reader = response.body?.getReader();
+ if (!reader) throw new Error("No response body");
+
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let fullText = "";
+ const toolCallAccumulator = new Map<
+ number,
+ { id: string; name: string; arguments: string }
+ >();
+
+ try {
+ while (true) {
+ if (context.signal?.aborted) break;
+
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() ?? "";
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed.startsWith("data: ")) continue;
+ const data = trimmed.slice(6);
+ if (data === "[DONE]") continue;
+
+ let parsed: any;
+ try {
+ parsed = JSON.parse(data);
+ } catch {
+ continue;
+ }
+
+ const delta = parsed.choices?.[0]?.delta;
+ if (!delta) continue;
+
+ if (delta.content) {
+ fullText += delta.content;
+ yield { type: "message_delta" as const, content: delta.content };
+ }
+
+ if (delta.tool_calls) {
+ for (const tc of delta.tool_calls as DeltaToolCall[]) {
+ const existing = toolCallAccumulator.get(tc.index);
+ if (existing) {
+ if (tc.function?.arguments) {
+ existing.arguments += tc.function.arguments;
+ }
+ } else {
+ toolCallAccumulator.set(tc.index, {
+ id: tc.id ?? `call_${tc.index}`,
+ name: tc.function?.name ?? "",
+ arguments: tc.function?.arguments ?? "",
+ });
+ }
+ }
+ }
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+
+ const toolCalls: OpenAIToolCall[] = Array.from(
+ toolCallAccumulator.values(),
+ ).map((tc) => ({
+ id: tc.id,
+ type: "function" as const,
+ function: { name: tc.name, arguments: tc.arguments },
+ }));
+
+ return { text: fullText, toolCalls };
+ }
+
+ private async *executeToolCalls(
+ calls: Array<{ name: string; args: unknown }>,
+ messages: OpenAIMessage[],
+ context: AgentRunContext,
+ ): AsyncGenerator {
+ const toolCallObjs: OpenAIToolCall[] = calls.map((c, i) => ({
+ id: `text_call_${i}`,
+ type: "function" as const,
+ function: {
+ name: c.name,
+ arguments: JSON.stringify(c.args),
+ },
+ }));
+
+ messages.push({
+ role: "assistant",
+ content: null,
+ tool_calls: toolCallObjs,
+ });
+
+ for (const tc of toolCallObjs) {
+ const name = tc.function.name;
+ let args: unknown;
+ try {
+ args = JSON.parse(tc.function.arguments);
+ } catch {
+ args = {};
+ }
+
+ yield { type: "tool_call", callId: tc.id, name, args };
+
+ try {
+ const result = await context.executeTool(name, args);
+ const resultStr =
+ typeof result === "string" ? result : JSON.stringify(result);
+
+ yield { type: "tool_result", callId: tc.id, result };
+
+ messages.push({
+ role: "tool",
+ content: resultStr,
+ tool_call_id: tc.id,
+ });
+ } catch (error) {
+ const errMsg =
+ error instanceof Error ? error.message : "Tool execution failed";
+
+ yield {
+ type: "tool_result",
+ callId: tc.id,
+ result: null,
+ error: errMsg,
+ };
+
+ messages.push({
+ role: "tool",
+ content: JSON.stringify({ error: errMsg }),
+ tool_call_id: tc.id,
+ });
+ }
+ }
+ }
+
+ private buildMessages(messages: AgentInput["messages"]): OpenAIMessage[] {
+ return messages.map((m) => ({
+ role: m.role as OpenAIMessage["role"],
+ content: m.content,
+ }));
+ }
+
+ private buildTools(
+ definitions: AgentToolDefinition[],
+ nameToWire: Map,
+ ): OpenAITool[] {
+ return definitions.map((def) => ({
+ type: "function" as const,
+ function: {
+ name: nameToWire.get(def.name) ?? def.name,
+ description: def.description,
+ parameters: def.parameters,
+ },
+ }));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Vercel AI SDK helper
+// ---------------------------------------------------------------------------
+
+/**
+ * Creates a Vercel AI-compatible model backed by a Databricks Model Serving endpoint.
+ *
+ * Use with `VercelAIAdapter` to get the Vercel AI SDK ecosystem (useChat, etc.)
+ * while targeting a Databricks `/invocations` endpoint.
+ *
+ * Handles URL rewriting (`/chat/completions` -> `/invocations`), per-request
+ * auth refresh, and tool name sanitization (dots -> double-underscores).
+ *
+ * Requires the `ai` and `@ai-sdk/openai` packages as peer dependencies.
+ *
+ * @example
+ * ```ts
+ * import { createDatabricksModel } from "@databricks/appkit/agents/databricks";
+ * import { VercelAIAdapter } from "@databricks/appkit/agents/vercel-ai";
+ * import { WorkspaceClient } from "@databricks/sdk-experimental";
+ *
+ * const model = await createDatabricksModel({
+ * workspaceClient: new WorkspaceClient({}),
+ * endpointName: "my-endpoint",
+ * });
+ * appkit.agent.registerAgent("assistant", new VercelAIAdapter({ model }));
+ * ```
+ */
+export async function createDatabricksModel(
+ options: ServingEndpointOptions,
+): Promise {
+ let createOpenAI: any;
+ try {
+ // @ts-expect-error -- optional peer dependency, may not be installed
+ const mod = await import("@ai-sdk/openai");
+ createOpenAI = mod.createOpenAI;
+ } catch {
+ throw new Error(
+ "createDatabricksModel requires '@ai-sdk/openai' as a dependency. Install it with: npm install @ai-sdk/openai ai",
+ );
+ }
+
+ const config = options.workspaceClient.config;
+ await config.ensureResolved();
+
+ const baseURL = `${config.host}/serving-endpoints/${options.endpointName}`;
+
+ const provider = createOpenAI({
+ baseURL,
+ apiKey: "databricks",
+ fetch: async (url: string | URL | Request, init?: RequestInit) => {
+ const rewritten = String(url).replace(
+ "/chat/completions",
+ "/invocations",
+ );
+
+ const headers = new Headers(init?.headers);
+ await config.authenticate(headers);
+
+ let body = init?.body;
+ if (typeof body === "string") {
+ body = rewriteToolNamesOutbound(body);
+ }
+
+ const response = await globalThis.fetch(rewritten, {
+ ...init,
+ headers,
+ body,
+ });
+
+ if (
+ !response.body ||
+ !response.headers.get("content-type")?.includes("text/event-stream")
+ ) {
+ return response;
+ }
+
+ const transformed = response.body.pipeThrough(
+ createToolNameRewriteStream(),
+ );
+
+ return new Response(transformed, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: response.headers,
+ });
+ },
+ });
+
+ return provider(options.endpointName);
+}
+
+/**
+ * Rewrites tool names in outbound request body (dots -> double-underscores).
+ */
+function rewriteToolNamesOutbound(body: string): string {
+ try {
+ const parsed = JSON.parse(body);
+ if (parsed.tools) {
+ for (const tool of parsed.tools) {
+ if (tool.function?.name) {
+ tool.function.name = tool.function.name.replace(/\./g, "__");
+ }
+ }
+ }
+ return JSON.stringify(parsed);
+ } catch {
+ return body;
+ }
+}
+
+/**
+ * Creates a TransformStream that rewrites tool names in SSE response chunks
+ * (double-underscores -> dots).
+ */
+function createToolNameRewriteStream(): TransformStream<
+ Uint8Array,
+ Uint8Array
+> {
+ const decoder = new TextDecoder();
+ const encoder = new TextEncoder();
+
+ return new TransformStream({
+ transform(chunk, controller) {
+ const text = decoder.decode(chunk, { stream: true });
+ const rewritten = text.replace(
+ /"name"\s*:\s*"([a-zA-Z0-9_-]+)"/g,
+ (match, name: string) => {
+ if (name.includes("__")) {
+ return match.replace(name, name.replace(/__/g, "."));
+ }
+ return match;
+ },
+ );
+ controller.enqueue(encoder.encode(rewritten));
+ },
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Text-based tool call parsing (fallback)
+// ---------------------------------------------------------------------------
+
+/**
+ * Parses text-based tool calls from model output.
+ *
+ * Handles two formats:
+ * 1. Llama native: `[{"name": "tool_name", "parameters": {"arg": "val"}}]`
+ * 2. Python-style: `[tool_name(arg1='val1', arg2='val2')]`
+ */
+export function parseTextToolCalls(
+ text: string,
+): Array<{ name: string; args: unknown }> {
+ const trimmed = text.trim();
+
+ const jsonResult = tryParseLlamaJsonToolCalls(trimmed);
+ if (jsonResult.length > 0) return jsonResult;
+
+ const pyResult = tryParsePythonStyleToolCalls(trimmed);
+ if (pyResult.length > 0) return pyResult;
+
+ return [];
+}
+
+function tryParseLlamaJsonToolCalls(
+ text: string,
+): Array<{ name: string; args: unknown }> {
+ const match = text.match(/\[\s*\{[\s\S]*\}\s*\]/);
+ if (!match) return [];
+
+ try {
+ const parsed = JSON.parse(match[0]);
+ if (!Array.isArray(parsed)) return [];
+
+ return parsed
+ .filter(
+ (item: any) =>
+ typeof item === "object" &&
+ item !== null &&
+ typeof item.name === "string",
+ )
+ .map((item: any) => ({
+ name: item.name,
+ args: item.parameters ?? item.arguments ?? item.args ?? {},
+ }));
+ } catch {
+ return [];
+ }
+}
+
+function tryParsePythonStyleToolCalls(
+ text: string,
+): Array<{ name: string; args: unknown }> {
+ const pattern = /\[?([a-zA-Z_][\w.]*)\(([^)]*)\)\]?/g;
+ const results: Array<{ name: string; args: unknown }> = [];
+
+ for (const match of text.matchAll(pattern)) {
+ const name = match[1];
+ const argsStr = match[2];
+
+ const args: Record = {};
+ const argPattern = /(\w+)\s*=\s*(?:'([^']*)'|"([^"]*)"|(\S+))/g;
+ for (const argMatch of argsStr.matchAll(argPattern)) {
+ const key = argMatch[1];
+ const value = argMatch[2] ?? argMatch[3] ?? argMatch[4];
+ args[key] = value;
+ }
+
+ results.push({ name, args });
+ }
+
+ return results;
+}
diff --git a/packages/appkit/src/agents/langchain.ts b/packages/appkit/src/agents/langchain.ts
new file mode 100644
index 00000000..9fc184ed
--- /dev/null
+++ b/packages/appkit/src/agents/langchain.ts
@@ -0,0 +1,197 @@
+import type {
+ AgentAdapter,
+ AgentEvent,
+ AgentInput,
+ AgentRunContext,
+ AgentToolDefinition,
+} from "shared";
+
+/**
+ * Adapter bridging LangChain/LangGraph to the AppKit agent protocol.
+ *
+ * Accepts any LangChain `Runnable` (e.g. AgentExecutor, compiled LangGraph)
+ * and maps `streamEvents` v2 to `AgentEvent`.
+ *
+ * Requires `@langchain/core` as an optional peer dependency.
+ *
+ * @example
+ * ```ts
+ * import { LangChainAdapter } from "@databricks/appkit/agents/langchain";
+ * import { ChatOpenAI } from "@langchain/openai";
+ *
+ * const model = new ChatOpenAI({ model: "gpt-4o" });
+ * const agentExecutor = createReactAgent({ llm: model, tools: [] });
+ * appkit.agent.registerAgent("assistant", new LangChainAdapter({ runnable: agentExecutor }));
+ * ```
+ */
+export class LangChainAdapter implements AgentAdapter {
+ private runnable: any;
+
+ constructor(options: { runnable: any }) {
+ this.runnable = options.runnable;
+ }
+
+ async *run(
+ input: AgentInput,
+ context: AgentRunContext,
+ ): AsyncGenerator {
+ // @ts-expect-error -- optional peer dependency, may not be installed
+ const lcTools = await import("@langchain/core/tools");
+ const DynamicStructuredTool = lcTools.DynamicStructuredTool;
+ const zodModule: any = await import("zod");
+ const z = zodModule.z;
+
+ const tools = this.buildTools(
+ input.tools,
+ context,
+ DynamicStructuredTool,
+ z,
+ );
+
+ const messages = input.messages.map((m) => ({
+ role: m.role,
+ content: m.content,
+ }));
+
+ yield { type: "status", status: "running" };
+
+ const runnableWithTools =
+ tools.length > 0 && typeof this.runnable.bindTools === "function"
+ ? this.runnable.bindTools(tools)
+ : this.runnable;
+
+ const stream = await runnableWithTools.streamEvents(
+ { messages },
+ {
+ version: "v2",
+ signal: input.signal,
+ },
+ );
+
+ for await (const event of stream) {
+ if (context.signal?.aborted) break;
+
+ switch (event.event) {
+ case "on_chat_model_stream": {
+ const chunk = event.data?.chunk;
+ if (chunk?.content && typeof chunk.content === "string") {
+ yield { type: "message_delta", content: chunk.content };
+ }
+ if (chunk?.tool_call_chunks) {
+ for (const tc of chunk.tool_call_chunks) {
+ if (tc.name) {
+ yield {
+ type: "tool_call",
+ callId: tc.id ?? tc.name,
+ name: tc.name,
+ args: tc.args ? JSON.parse(tc.args) : {},
+ };
+ }
+ }
+ }
+ break;
+ }
+
+ case "on_tool_end": {
+ const output = event.data?.output;
+ yield {
+ type: "tool_result",
+ callId: event.run_id,
+ result: output?.content ?? output,
+ };
+ break;
+ }
+
+ case "on_chain_end": {
+ const output = event.data?.output;
+ if (output?.content && typeof output.content === "string") {
+ yield { type: "message", content: output.content };
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Converts AgentToolDefinitions into LangChain DynamicStructuredTool instances.
+ *
+ * JSON Schema properties are mapped to Zod schemas using a lightweight
+ * recursive converter for the subset of JSON Schema types that tools use.
+ */
+ private buildTools(
+ definitions: AgentToolDefinition[],
+ context: AgentRunContext,
+ DynamicStructuredTool: any,
+ z: any,
+ ): any[] {
+ return definitions.map(
+ (def) =>
+ new DynamicStructuredTool({
+ name: def.name,
+ description: def.description,
+ schema: jsonSchemaToZod(def.parameters, z),
+ func: async (args: unknown) => {
+ try {
+ const result = await context.executeTool(def.name, args);
+ return typeof result === "string"
+ ? result
+ : JSON.stringify(result);
+ } catch (error) {
+ return `Error: ${error instanceof Error ? error.message : "Tool execution failed"}`;
+ }
+ },
+ }),
+ );
+ }
+}
+
+/**
+ * Lightweight JSON Schema (subset) to Zod converter.
+ * Handles the types commonly used in tool parameters.
+ */
+function jsonSchemaToZod(schema: any, z: any): any {
+ if (!schema) return z.object({});
+
+ switch (schema.type) {
+ case "object": {
+ const shape: Record = {};
+ const properties = schema.properties ?? {};
+ const required = new Set(schema.required ?? []);
+
+ for (const [key, prop] of Object.entries(properties)) {
+ let field = jsonSchemaToZod(prop, z);
+ if (!required.has(key)) {
+ field = field.optional();
+ }
+ if ((prop as any).description) {
+ field = field.describe((prop as any).description);
+ }
+ shape[key] = field;
+ }
+ return z.object(shape);
+ }
+
+ case "array":
+ return z.array(jsonSchemaToZod(schema.items ?? {}, z));
+
+ case "string": {
+ let s = z.string();
+ if (schema.enum) s = z.enum(schema.enum);
+ return s;
+ }
+
+ case "number":
+ case "integer":
+ return z.number();
+
+ case "boolean":
+ return z.boolean();
+
+ case "null":
+ return z.null();
+
+ default:
+ return z.any();
+ }
+}
diff --git a/packages/appkit/src/agents/tests/databricks.test.ts b/packages/appkit/src/agents/tests/databricks.test.ts
new file mode 100644
index 00000000..9b51f6c4
--- /dev/null
+++ b/packages/appkit/src/agents/tests/databricks.test.ts
@@ -0,0 +1,406 @@
+import type { AgentEvent, AgentToolDefinition, Message } from "shared";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { DatabricksAdapter, parseTextToolCalls } from "../databricks";
+
+const mockAuthenticate = vi
+ .fn()
+ .mockResolvedValue({ Authorization: "Bearer test-token" });
+
+function sseChunk(data: string): string {
+ return `data: ${data}\n\n`;
+}
+
+function textDelta(content: string): string {
+ return sseChunk(
+ JSON.stringify({
+ choices: [{ delta: { content } }],
+ }),
+ );
+}
+
+function toolCallDelta(
+ index: number,
+ id: string | undefined,
+ name: string | undefined,
+ args: string,
+): string {
+ return sseChunk(
+ JSON.stringify({
+ choices: [
+ {
+ delta: {
+ tool_calls: [
+ {
+ index,
+ ...(id && { id }),
+ ...(name && { type: "function" }),
+ function: {
+ ...(name && { name }),
+ arguments: args,
+ },
+ },
+ ],
+ },
+ },
+ ],
+ }),
+ );
+}
+
+function createReadableStream(chunks: string[]): ReadableStream {
+ const encoder = new TextEncoder();
+ let i = 0;
+ return new ReadableStream({
+ pull(controller) {
+ if (i < chunks.length) {
+ controller.enqueue(encoder.encode(chunks[i]));
+ i++;
+ } else {
+ controller.close();
+ }
+ },
+ });
+}
+
+function mockFetch(chunks: string[]): typeof globalThis.fetch {
+ return vi.fn().mockResolvedValue({
+ ok: true,
+ body: createReadableStream(chunks),
+ text: () => Promise.resolve(""),
+ });
+}
+
+function createTestMessages(): Message[] {
+ return [{ id: "1", role: "user", content: "Hello", createdAt: new Date() }];
+}
+
+function createTestTools(): AgentToolDefinition[] {
+ return [
+ {
+ name: "analytics.query",
+ description: "Run SQL",
+ parameters: {
+ type: "object",
+ properties: { query: { type: "string" } },
+ required: ["query"],
+ },
+ },
+ ];
+}
+
+function createAdapter(
+ overrides?: Partial[0]>,
+) {
+ return new DatabricksAdapter({
+ endpointUrl:
+ "https://test.databricks.com/serving-endpoints/my-endpoint/invocations",
+ authenticate: mockAuthenticate,
+ ...overrides,
+ });
+}
+
+describe("DatabricksAdapter", () => {
+ const originalFetch = globalThis.fetch;
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ mockAuthenticate.mockClear();
+ });
+
+ test("streams text deltas from the model", async () => {
+ globalThis.fetch = mockFetch([
+ textDelta("Hello"),
+ textDelta(" world"),
+ sseChunk("[DONE]"),
+ ]);
+
+ const adapter = createAdapter();
+ const events: AgentEvent[] = [];
+
+ for await (const event of adapter.run(
+ { messages: createTestMessages(), tools: [], threadId: "t1" },
+ { executeTool: vi.fn() },
+ )) {
+ events.push(event);
+ }
+
+ expect(events[0]).toEqual({ type: "status", status: "running" });
+ expect(events[1]).toEqual({ type: "message_delta", content: "Hello" });
+ expect(events[2]).toEqual({ type: "message_delta", content: " world" });
+ });
+
+ test("calls authenticate() per request for fresh headers", async () => {
+ globalThis.fetch = mockFetch([textDelta("Hi"), sseChunk("[DONE]")]);
+
+ const adapter = createAdapter();
+
+ for await (const _ of adapter.run(
+ { messages: createTestMessages(), tools: [], threadId: "t1" },
+ { executeTool: vi.fn() },
+ )) {
+ // drain
+ }
+
+ expect(mockAuthenticate).toHaveBeenCalledTimes(1);
+
+ const [, init] = (globalThis.fetch as any).mock.calls[0];
+ expect(init.headers.Authorization).toBe("Bearer test-token");
+ });
+
+ test("handles structured tool calls and executes them", async () => {
+ const executeTool = vi.fn().mockResolvedValue([{ trip_id: 1 }]);
+
+ let callCount = 0;
+ globalThis.fetch = vi.fn().mockImplementation(() => {
+ callCount++;
+ if (callCount === 1) {
+ return Promise.resolve({
+ ok: true,
+ body: createReadableStream([
+ toolCallDelta(0, "call_1", "analytics__query", ""),
+ toolCallDelta(0, undefined, undefined, '{"query":'),
+ toolCallDelta(0, undefined, undefined, '"SELECT 1"}'),
+ sseChunk("[DONE]"),
+ ]),
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ body: createReadableStream([
+ textDelta("Here are the results"),
+ sseChunk("[DONE]"),
+ ]),
+ });
+ });
+
+ const adapter = createAdapter();
+ const events: AgentEvent[] = [];
+
+ for await (const event of adapter.run(
+ {
+ messages: createTestMessages(),
+ tools: createTestTools(),
+ threadId: "t1",
+ },
+ { executeTool },
+ )) {
+ events.push(event);
+ }
+
+ expect(events).toContainEqual({
+ type: "tool_call",
+ callId: "call_1",
+ name: "analytics.query",
+ args: { query: "SELECT 1" },
+ });
+
+ expect(executeTool).toHaveBeenCalledWith("analytics.query", {
+ query: "SELECT 1",
+ });
+
+ expect(events).toContainEqual(
+ expect.objectContaining({
+ type: "tool_result",
+ callId: "call_1",
+ result: [{ trip_id: 1 }],
+ }),
+ );
+
+ expect(events).toContainEqual({
+ type: "message_delta",
+ content: "Here are the results",
+ });
+
+ // authenticate() called once per streamCompletion
+ expect(mockAuthenticate).toHaveBeenCalledTimes(2);
+ });
+
+ test("respects maxSteps limit", async () => {
+ globalThis.fetch = vi.fn().mockImplementation(() =>
+ Promise.resolve({
+ ok: true,
+ body: createReadableStream([
+ toolCallDelta(
+ 0,
+ "call_loop",
+ "analytics__query",
+ '{"query":"SELECT 1"}',
+ ),
+ sseChunk("[DONE]"),
+ ]),
+ }),
+ );
+
+ const adapter = createAdapter({ maxSteps: 2 });
+ const events: AgentEvent[] = [];
+
+ for await (const event of adapter.run(
+ {
+ messages: createTestMessages(),
+ tools: createTestTools(),
+ threadId: "t1",
+ },
+ { executeTool: vi.fn().mockResolvedValue("ok") },
+ )) {
+ events.push(event);
+ }
+
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
+ });
+
+ test("sends correct request to endpoint URL", async () => {
+ globalThis.fetch = mockFetch([textDelta("Hi"), sseChunk("[DONE]")]);
+
+ const adapter = createAdapter({ systemPrompt: "Be helpful" });
+
+ for await (const _ of adapter.run(
+ {
+ messages: createTestMessages(),
+ tools: createTestTools(),
+ threadId: "t1",
+ },
+ { executeTool: vi.fn() },
+ )) {
+ // drain
+ }
+
+ const [url, init] = (globalThis.fetch as any).mock.calls[0];
+ expect(url).toBe(
+ "https://test.databricks.com/serving-endpoints/my-endpoint/invocations",
+ );
+
+ const body = JSON.parse(init.body);
+ expect(body.stream).toBe(true);
+ expect(body.tools).toHaveLength(1);
+ expect(body.tools[0].function.name).toBe("analytics__query");
+ expect(body.messages[0]).toEqual({
+ role: "system",
+ content: "Be helpful",
+ });
+ });
+
+ test("throws on non-ok response", async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 401,
+ text: () => Promise.resolve("Unauthorized"),
+ });
+
+ const adapter = createAdapter();
+
+ await expect(async () => {
+ for await (const _ of adapter.run(
+ { messages: createTestMessages(), tools: [], threadId: "t1" },
+ { executeTool: vi.fn() },
+ )) {
+ // drain
+ }
+ }).rejects.toThrow("Databricks API error (401): Unauthorized");
+ });
+});
+
+describe("DatabricksAdapter.fromServingEndpoint", () => {
+ const originalFetch = globalThis.fetch;
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ });
+
+ test("builds endpointUrl from config host and endpoint name", async () => {
+ globalThis.fetch = mockFetch([textDelta("Hi"), sseChunk("[DONE]")]);
+
+ const mockConfig = {
+ host: "https://my-workspace.databricks.com",
+ ensureResolved: vi.fn().mockResolvedValue(undefined),
+ authenticate: vi.fn().mockImplementation(async (h: Headers) => {
+ h.set("Authorization", "Bearer fresh-token");
+ }),
+ };
+
+ const adapter = await DatabricksAdapter.fromServingEndpoint({
+ workspaceClient: { config: mockConfig },
+ endpointName: "my-model",
+ });
+
+ for await (const _ of adapter.run(
+ { messages: createTestMessages(), tools: [], threadId: "t1" },
+ { executeTool: vi.fn() },
+ )) {
+ // drain
+ }
+
+ const [url, init] = (globalThis.fetch as any).mock.calls[0];
+ expect(url).toBe(
+ "https://my-workspace.databricks.com/serving-endpoints/my-model/invocations",
+ );
+ expect(init.headers.authorization).toBe("Bearer fresh-token");
+ expect(mockConfig.ensureResolved).toHaveBeenCalled();
+ expect(mockConfig.authenticate).toHaveBeenCalled();
+ });
+});
+
+describe("parseTextToolCalls", () => {
+ test("parses Llama JSON format", () => {
+ const text =
+ '[{"name": "analytics.query", "parameters": {"query": "SELECT 1"}}]';
+ const result = parseTextToolCalls(text);
+
+ expect(result).toEqual([
+ { name: "analytics.query", args: { query: "SELECT 1" } },
+ ]);
+ });
+
+ test("parses multiple Llama JSON tool calls", () => {
+ const text =
+ '[{"name": "analytics.query", "parameters": {"query": "SELECT 1"}}, {"name": "files.uploads.list", "parameters": {}}]';
+ const result = parseTextToolCalls(text);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].name).toBe("analytics.query");
+ expect(result[1].name).toBe("files.uploads.list");
+ });
+
+ test("parses Python-style tool calls", () => {
+ const text =
+ "[analytics.query(query='SELECT * FROM trips ORDER BY date DESC LIMIT 10')]";
+ const result = parseTextToolCalls(text);
+
+ expect(result).toEqual([
+ {
+ name: "analytics.query",
+ args: {
+ query: "SELECT * FROM trips ORDER BY date DESC LIMIT 10",
+ },
+ },
+ ]);
+ });
+
+ test("parses Python-style with multiple args", () => {
+ const text =
+ "[files.uploads.read(path='/data/file.csv', encoding='utf-8')]";
+ const result = parseTextToolCalls(text);
+
+ expect(result).toEqual([
+ {
+ name: "files.uploads.read",
+ args: { path: "/data/file.csv", encoding: "utf-8" },
+ },
+ ]);
+ });
+
+ test("returns empty array for plain text", () => {
+ expect(parseTextToolCalls("Hello, how can I help?")).toEqual([]);
+ expect(parseTextToolCalls("")).toEqual([]);
+ expect(parseTextToolCalls("The answer is 42")).toEqual([]);
+ });
+
+ test("handles Llama format with 'arguments' key", () => {
+ const text =
+ '[{"name": "lakebase.query", "arguments": {"text": "SELECT 1"}}]';
+ const result = parseTextToolCalls(text);
+
+ expect(result).toEqual([
+ { name: "lakebase.query", args: { text: "SELECT 1" } },
+ ]);
+ });
+});
diff --git a/packages/appkit/src/agents/tests/langchain.test.ts b/packages/appkit/src/agents/tests/langchain.test.ts
new file mode 100644
index 00000000..a0249e93
--- /dev/null
+++ b/packages/appkit/src/agents/tests/langchain.test.ts
@@ -0,0 +1,176 @@
+import type { AgentEvent, AgentToolDefinition, Message } from "shared";
+import { describe, expect, test, vi } from "vitest";
+import { LangChainAdapter } from "../langchain";
+
+vi.mock("@langchain/core/tools", () => ({
+ DynamicStructuredTool: vi.fn().mockImplementation((config: any) => ({
+ name: config.name,
+ description: config.description,
+ schema: config.schema,
+ func: config.func,
+ })),
+}));
+
+vi.mock("zod", () => {
+ const createChainable = (base: Record = {}): any => {
+ const obj: any = { ...base };
+ obj.optional = () => createChainable({ ...obj, _optional: true });
+ obj.describe = (d: string) => createChainable({ ...obj, _description: d });
+ return obj;
+ };
+
+ return {
+ z: {
+ object: (shape: any) => createChainable({ type: "object", shape }),
+ string: () => createChainable({ type: "string" }),
+ number: () => createChainable({ type: "number" }),
+ boolean: () => createChainable({ type: "boolean" }),
+ array: (item: any) => createChainable({ type: "array", item }),
+ enum: (vals: any) => createChainable({ type: "enum", values: vals }),
+ any: () => createChainable({ type: "any" }),
+ null: () => createChainable({ type: "null" }),
+ },
+ };
+});
+
+function createTestMessages(): Message[] {
+ return [{ id: "1", role: "user", content: "Hello", createdAt: new Date() }];
+}
+
+function createTestTools(): AgentToolDefinition[] {
+ return [
+ {
+ name: "lakebase.query",
+ description: "Run SQL",
+ parameters: {
+ type: "object",
+ properties: {
+ text: { type: "string", description: "SQL query" },
+ values: { type: "array", items: {} },
+ },
+ required: ["text"],
+ },
+ },
+ ];
+}
+
+describe("LangChainAdapter", () => {
+ test("yields status running on start and maps chat_model_stream", async () => {
+ async function* mockStreamEvents() {
+ yield {
+ event: "on_chat_model_stream",
+ data: { chunk: { content: "Hello" } },
+ };
+ yield {
+ event: "on_chat_model_stream",
+ data: { chunk: { content: " world" } },
+ };
+ }
+
+ const mockRunnable = {
+ bindTools: vi.fn().mockReturnValue({
+ streamEvents: vi.fn().mockResolvedValue(mockStreamEvents()),
+ }),
+ };
+
+ const adapter = new LangChainAdapter({ runnable: mockRunnable });
+ const events: AgentEvent[] = [];
+
+ for await (const event of adapter.run(
+ {
+ messages: createTestMessages(),
+ tools: createTestTools(),
+ threadId: "t1",
+ },
+ { executeTool: vi.fn() },
+ )) {
+ events.push(event);
+ }
+
+ expect(events[0]).toEqual({ type: "status", status: "running" });
+ expect(events[1]).toEqual({ type: "message_delta", content: "Hello" });
+ expect(events[2]).toEqual({ type: "message_delta", content: " world" });
+ });
+
+ test("maps on_tool_end events to tool_result", async () => {
+ async function* mockStreamEvents() {
+ yield {
+ event: "on_tool_end",
+ run_id: "run-1",
+ data: { output: { content: "42 rows" } },
+ };
+ }
+
+ const mockRunnable = {
+ bindTools: vi.fn().mockReturnValue({
+ streamEvents: vi.fn().mockResolvedValue(mockStreamEvents()),
+ }),
+ };
+
+ const adapter = new LangChainAdapter({ runnable: mockRunnable });
+ const events: AgentEvent[] = [];
+
+ for await (const event of adapter.run(
+ {
+ messages: createTestMessages(),
+ tools: createTestTools(),
+ threadId: "t1",
+ },
+ { executeTool: vi.fn() },
+ )) {
+ events.push(event);
+ }
+
+ expect(events).toContainEqual({
+ type: "tool_result",
+ callId: "run-1",
+ result: "42 rows",
+ });
+ });
+
+ test("calls bindTools when tools are provided", async () => {
+ const streamEvents = vi.fn().mockResolvedValue((async function* () {})());
+ const bindTools = vi.fn().mockReturnValue({ streamEvents });
+
+ const adapter = new LangChainAdapter({
+ runnable: { bindTools },
+ });
+
+ for await (const _ of adapter.run(
+ {
+ messages: createTestMessages(),
+ tools: createTestTools(),
+ threadId: "t1",
+ },
+ { executeTool: vi.fn() },
+ )) {
+ // drain
+ }
+
+ expect(bindTools).toHaveBeenCalledTimes(1);
+ expect(bindTools.mock.calls[0][0]).toHaveLength(1);
+ expect(bindTools.mock.calls[0][0][0].name).toBe("lakebase.query");
+ });
+
+ test("does not call bindTools when no tools provided", async () => {
+ const streamEvents = vi.fn().mockResolvedValue((async function* () {})());
+ const bindTools = vi.fn().mockReturnValue({ streamEvents });
+
+ const adapter = new LangChainAdapter({
+ runnable: { bindTools, streamEvents },
+ });
+
+ for await (const _ of adapter.run(
+ {
+ messages: createTestMessages(),
+ tools: [],
+ threadId: "t1",
+ },
+ { executeTool: vi.fn() },
+ )) {
+ // drain
+ }
+
+ expect(bindTools).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/appkit/src/agents/tests/vercel-ai.test.ts b/packages/appkit/src/agents/tests/vercel-ai.test.ts
new file mode 100644
index 00000000..7280c9aa
--- /dev/null
+++ b/packages/appkit/src/agents/tests/vercel-ai.test.ts
@@ -0,0 +1,190 @@
+import type { AgentEvent, AgentToolDefinition, Message } from "shared";
+import { describe, expect, test, vi } from "vitest";
+import { VercelAIAdapter } from "../vercel-ai";
+
+vi.mock("ai", () => ({
+ streamText: vi.fn(),
+ jsonSchema: vi.fn((schema: any) => schema),
+}));
+
+function createTestMessages(): Message[] {
+ return [
+ {
+ id: "1",
+ role: "user",
+ content: "Hello",
+ createdAt: new Date(),
+ },
+ ];
+}
+
+function createTestTools(): AgentToolDefinition[] {
+ return [
+ {
+ name: "analytics.query",
+ description: "Run SQL",
+ parameters: {
+ type: "object",
+ properties: {
+ query: { type: "string" },
+ },
+ required: ["query"],
+ },
+ },
+ ];
+}
+
+describe("VercelAIAdapter", () => {
+ test("yields status running on start", async () => {
+ const { streamText } = await import("ai");
+
+ async function* mockStream() {
+ yield { type: "text-delta", textDelta: "Hi" };
+ }
+
+ (streamText as any).mockReturnValue({
+ fullStream: mockStream(),
+ });
+
+ const adapter = new VercelAIAdapter({ model: {} });
+ const events: AgentEvent[] = [];
+
+ const stream = adapter.run(
+ {
+ messages: createTestMessages(),
+ tools: createTestTools(),
+ threadId: "t1",
+ },
+ {
+ executeTool: vi.fn(),
+ },
+ );
+
+ for await (const event of stream) {
+ events.push(event);
+ }
+
+ expect(events[0]).toEqual({ type: "status", status: "running" });
+ expect(events[1]).toEqual({ type: "message_delta", content: "Hi" });
+ });
+
+ test("maps tool-call and tool-result events", async () => {
+ const { streamText } = await import("ai");
+
+ async function* mockStream() {
+ yield {
+ type: "tool-call",
+ toolCallId: "c1",
+ toolName: "analytics.query",
+ args: { query: "SELECT 1" },
+ };
+ yield {
+ type: "tool-result",
+ toolCallId: "c1",
+ result: [{ value: 1 }],
+ };
+ }
+
+ (streamText as any).mockReturnValue({
+ fullStream: mockStream(),
+ });
+
+ const adapter = new VercelAIAdapter({ model: {} });
+ const events: AgentEvent[] = [];
+
+ for await (const event of adapter.run(
+ {
+ messages: createTestMessages(),
+ tools: createTestTools(),
+ threadId: "t1",
+ },
+ { executeTool: vi.fn() },
+ )) {
+ events.push(event);
+ }
+
+ expect(events).toContainEqual({
+ type: "tool_call",
+ callId: "c1",
+ name: "analytics.query",
+ args: { query: "SELECT 1" },
+ });
+
+ expect(events).toContainEqual({
+ type: "tool_result",
+ callId: "c1",
+ result: [{ value: 1 }],
+ });
+ });
+
+ test("maps error events", async () => {
+ const { streamText } = await import("ai");
+
+ async function* mockStream() {
+ yield { type: "error", error: "API rate limited" };
+ }
+
+ (streamText as any).mockReturnValue({
+ fullStream: mockStream(),
+ });
+
+ const adapter = new VercelAIAdapter({ model: {} });
+ const events: AgentEvent[] = [];
+
+ for await (const event of adapter.run(
+ {
+ messages: createTestMessages(),
+ tools: [],
+ threadId: "t1",
+ },
+ { executeTool: vi.fn() },
+ )) {
+ events.push(event);
+ }
+
+ expect(events).toContainEqual({
+ type: "status",
+ status: "error",
+ error: "API rate limited",
+ });
+ });
+
+ test("builds tools with execute functions that delegate to executeTool", async () => {
+ const { streamText } = await import("ai");
+
+ let capturedTools: Record = {};
+
+ (streamText as any).mockImplementation((opts: any) => {
+ capturedTools = opts.tools;
+ return {
+ fullStream: (async function* () {})(),
+ };
+ });
+
+ const executeTool = vi.fn().mockResolvedValue({ count: 42 });
+ const adapter = new VercelAIAdapter({ model: {} });
+
+ // Consume the stream to trigger streamText
+ for await (const _ of adapter.run(
+ {
+ messages: createTestMessages(),
+ tools: createTestTools(),
+ threadId: "t1",
+ },
+ { executeTool },
+ )) {
+ // drain
+ }
+
+ expect(capturedTools["analytics.query"]).toBeDefined();
+ expect(capturedTools["analytics.query"].description).toBe("Run SQL");
+
+ const result = await capturedTools["analytics.query"].execute({
+ query: "SELECT 1",
+ });
+ expect(executeTool).toHaveBeenCalledWith("analytics.query", {
+ query: "SELECT 1",
+ });
+ expect(result).toEqual({ count: 42 });
+ });
+});
diff --git a/packages/appkit/src/agents/vercel-ai.ts b/packages/appkit/src/agents/vercel-ai.ts
new file mode 100644
index 00000000..e2159493
--- /dev/null
+++ b/packages/appkit/src/agents/vercel-ai.ts
@@ -0,0 +1,129 @@
+import type {
+ AgentAdapter,
+ AgentEvent,
+ AgentInput,
+ AgentRunContext,
+ AgentToolDefinition,
+} from "shared";
+
+/**
+ * Adapter bridging the Vercel AI SDK (`ai` package) to the AppKit agent protocol.
+ *
+ * Converts `AgentToolDefinition[]` to Vercel AI tool format and maps
+ * `streamText().fullStream` events to `AgentEvent`.
+ *
+ * Requires `ai` as an optional peer dependency.
+ *
+ * @example
+ * ```ts
+ * import { VercelAIAdapter } from "@databricks/appkit/agents/vercel-ai";
+ * import { openai } from "@ai-sdk/openai";
+ *
+ * appkit.agent.registerAgent("assistant", new VercelAIAdapter({ model: openai("gpt-4o") }));
+ * ```
+ */
+export class VercelAIAdapter implements AgentAdapter {
+ private model: any;
+ private systemPrompt?: string;
+
+ constructor(options: { model: any; systemPrompt?: string }) {
+ this.model = options.model;
+ this.systemPrompt = options.systemPrompt;
+ }
+
+ async *run(
+ input: AgentInput,
+ context: AgentRunContext,
+ ): AsyncGenerator {
+ const { streamText } = await import("ai");
+ const { jsonSchema } = await import("ai");
+
+ const tools = this.buildTools(input.tools, context, jsonSchema);
+
+ const messages = input.messages.map((m) => ({
+ role: m.role as "user" | "assistant" | "system",
+ content: m.content,
+ }));
+
+ yield { type: "status", status: "running" };
+
+ const result = streamText({
+ model: this.model,
+ system: this.systemPrompt,
+ messages,
+ tools,
+ maxSteps: 10 as any,
+ abortSignal: input.signal,
+ } as any);
+
+ for await (const part of (result as any).fullStream) {
+ if (context.signal?.aborted) break;
+
+ switch (part.type) {
+ case "text-delta":
+ yield { type: "message_delta", content: part.textDelta };
+ break;
+
+ case "tool-call":
+ yield {
+ type: "tool_call",
+ callId: part.toolCallId,
+ name: part.toolName,
+ args: part.args,
+ };
+ break;
+
+ case "tool-result":
+ yield {
+ type: "tool_result",
+ callId: part.toolCallId,
+ result: part.result,
+ };
+ break;
+
+ case "reasoning":
+ if (part.textDelta) {
+ yield { type: "thinking", content: part.textDelta };
+ }
+ break;
+
+ case "error":
+ yield {
+ type: "status",
+ status: "error",
+ error: String(part.error),
+ };
+ break;
+ }
+ }
+ }
+
+ private buildTools(
+ definitions: AgentToolDefinition[],
+ context: AgentRunContext,
+ jsonSchema: any,
+ ): Record {
+ const tools: Record = {};
+
+ for (const def of definitions) {
+ tools[def.name] = {
+ description: def.description,
+ parameters: jsonSchema(def.parameters),
+ execute: async (args: unknown) => {
+ try {
+ return await context.executeTool(def.name, args);
+ } catch (error) {
+ return {
+ error:
+ error instanceof Error
+ ? error.message
+ : "Tool execution failed",
+ };
+ }
+ },
+ };
+ }
+
+ return tools;
+ }
+}
diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts
index 8db7f1d7..f697c50b 100644
--- a/packages/appkit/src/index.ts
+++ b/packages/appkit/src/index.ts
@@ -7,11 +7,20 @@
// Types from shared
export type {
+ AgentAdapter,
+ AgentEvent,
+ AgentInput,
+ AgentRunContext,
+ AgentToolDefinition,
BasePluginConfig,
CacheConfig,
IAppRouter,
+ Message,
PluginData,
StreamExecutionSettings,
+ Thread,
+ ThreadStore,
+ ToolProvider,
} from "shared";
export { isSQLTypeMarker, sql } from "shared";
export { CacheManager } from "./cache";
@@ -48,7 +57,7 @@ export {
} from "./errors";
// Plugin authoring
export { Plugin, type ToPlugin, toPlugin } from "./plugin";
-export { analytics, files, genie, lakebase, server } from "./plugins";
+export { agent, analytics, files, genie, lakebase, server } from "./plugins";
// Registry types and utilities for plugin manifests
export type {
ConfigSchema,
diff --git a/packages/appkit/src/plugins/agent/agent.ts b/packages/appkit/src/plugins/agent/agent.ts
new file mode 100644
index 00000000..0aa41bdb
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/agent.ts
@@ -0,0 +1,398 @@
+import { randomUUID } from "node:crypto";
+import type express from "express";
+import type {
+ AgentAdapter,
+ AgentEvent,
+ AgentToolDefinition,
+ IAppRouter,
+ Message,
+ PluginPhase,
+ ToolProvider,
+} from "shared";
+import { createLogger } from "../../logging/logger";
+import { Plugin, toPlugin } from "../../plugin";
+import type { PluginManifest } from "../../registry";
+import { agentStreamDefaults } from "./defaults";
+import manifest from "./manifest.json";
+import { InMemoryThreadStore } from "./thread-store";
+import type { AgentPluginConfig, RegisteredAgent, ToolEntry } from "./types";
+
+const logger = createLogger("agent");
+
+function isToolProvider(obj: unknown): obj is ToolProvider {
+ return (
+ typeof obj === "object" &&
+ obj !== null &&
+ "getAgentTools" in obj &&
+ typeof (obj as any).getAgentTools === "function" &&
+ "executeAgentTool" in obj &&
+ typeof (obj as any).executeAgentTool === "function"
+ );
+}
+
+export class AgentPlugin extends Plugin {
+ static manifest = manifest as PluginManifest<"agent">;
+ static phase: PluginPhase = "deferred";
+
+ protected declare config: AgentPluginConfig;
+
+ private agents = new Map();
+ private defaultAgentName: string | null = null;
+ private toolIndex = new Map();
+ private threadStore;
+ private activeStreams = new Map();
+
+ constructor(config: AgentPluginConfig) {
+ super(config);
+ this.config = config;
+ this.threadStore = config.threadStore ?? new InMemoryThreadStore();
+ }
+
+ async setup() {
+ this.collectTools();
+
+ if (this.config.agents) {
+ const entries = Object.entries(this.config.agents);
+ const resolved = await Promise.all(
+ entries.map(async ([name, adapterOrPromise]) => ({
+ name,
+ adapter: await adapterOrPromise,
+ })),
+ );
+ for (const { name, adapter } of resolved) {
+ this.agents.set(name, { name, adapter });
+ if (!this.defaultAgentName) {
+ this.defaultAgentName = name;
+ }
+ }
+ }
+
+ if (this.config.defaultAgent) {
+ this.defaultAgentName = this.config.defaultAgent;
+ }
+ }
+
+ private collectTools() {
+ const plugins = this.config.plugins;
+ if (!plugins) return;
+
+ for (const [pluginName, pluginInstance] of Object.entries(plugins)) {
+ if (pluginName === "agent") continue;
+ if (!isToolProvider(pluginInstance)) continue;
+
+ const tools = (pluginInstance as ToolProvider).getAgentTools();
+ for (const tool of tools) {
+ const qualifiedName = `${pluginName}.${tool.name}`;
+ this.toolIndex.set(qualifiedName, {
+ plugin: pluginInstance as ToolProvider & { asUser(req: any): any },
+ def: { ...tool, name: qualifiedName },
+ localName: tool.name,
+ });
+ }
+
+ logger.info(
+ "Collected %d tools from plugin %s",
+ tools.length,
+ pluginName,
+ );
+ }
+
+ logger.info("Total agent tools: %d", this.toolIndex.size);
+ }
+
+ injectRoutes(router: IAppRouter) {
+ this.route(router, {
+ name: "chat",
+ method: "post",
+ path: "/chat",
+ handler: async (req, res) => this._handleChat(req, res),
+ });
+
+ this.route(router, {
+ name: "cancel",
+ method: "post",
+ path: "/cancel",
+ handler: async (req, res) => this._handleCancel(req, res),
+ });
+
+ this.route(router, {
+ name: "threads",
+ method: "get",
+ path: "/threads",
+ handler: async (req, res) => this._handleListThreads(req, res),
+ });
+
+ this.route(router, {
+ name: "thread",
+ method: "get",
+ path: "/threads/:threadId",
+ handler: async (req, res) => this._handleGetThread(req, res),
+ });
+
+ this.route(router, {
+ name: "deleteThread",
+ method: "delete",
+ path: "/threads/:threadId",
+ handler: async (req, res) => this._handleDeleteThread(req, res),
+ });
+
+ this.route(router, {
+ name: "tools",
+ method: "get",
+ path: "/tools",
+ handler: async (req, res) => this._handleListTools(req, res),
+ });
+
+ this.route(router, {
+ name: "agents",
+ method: "get",
+ path: "/agents",
+ handler: async (_req, res) => {
+ res.json({
+ agents: Array.from(this.agents.keys()),
+ default: this.defaultAgentName,
+ });
+ },
+ });
+ }
+
+ private async _handleChat(
+ req: express.Request,
+ res: express.Response,
+ ): Promise {
+ const {
+ message,
+ threadId,
+ agent: agentName,
+ } = req.body as {
+ message?: string;
+ threadId?: string;
+ agent?: string;
+ };
+
+ if (!message) {
+ res.status(400).json({ error: "message is required" });
+ return;
+ }
+
+ const resolvedAgent = this.resolveAgent(agentName);
+ if (!resolvedAgent) {
+ res.status(400).json({
+ error: agentName
+ ? `Agent "${agentName}" not found`
+ : "No agent registered",
+ });
+ return;
+ }
+
+ const userId = this.resolveUserId(req);
+
+ let thread = threadId ? await this.threadStore.get(threadId, userId) : null;
+
+ if (threadId && !thread) {
+ res.status(404).json({ error: `Thread ${threadId} not found` });
+ return;
+ }
+
+ if (!thread) {
+ thread = await this.threadStore.create(userId);
+ }
+
+ const userMessage: Message = {
+ id: randomUUID(),
+ role: "user",
+ content: message,
+ createdAt: new Date(),
+ };
+ await this.threadStore.addMessage(thread.id, userId, userMessage);
+
+ const tools = this.getAllToolDefinitions();
+ const abortController = new AbortController();
+ const signal = abortController.signal;
+
+ const executeTool = async (
+ qualifiedName: string,
+ args: unknown,
+ ): Promise => {
+ const entry = this.toolIndex.get(qualifiedName);
+ if (!entry) throw new Error(`Unknown tool: ${qualifiedName}`);
+
+ const target = entry.def.annotations?.requiresUserContext
+ ? (entry.plugin as any).asUser(req)
+ : entry.plugin;
+
+ return (target as ToolProvider).executeAgentTool(
+ entry.localName,
+ args,
+ signal,
+ );
+ };
+
+ const requestId = randomUUID();
+ this.activeStreams.set(requestId, abortController);
+
+ const self = this;
+
+ await this.executeStream(
+ res,
+ async function* () {
+ try {
+ yield { type: "metadata" as const, data: { threadId: thread.id } };
+
+ const stream = resolvedAgent.adapter.run(
+ {
+ messages: [...thread.messages],
+ tools,
+ threadId: thread.id,
+ signal,
+ },
+ { executeTool, signal },
+ );
+
+ let fullContent = "";
+
+ for await (const event of stream) {
+ if (signal.aborted) break;
+
+ if (event.type === "message_delta") {
+ fullContent += event.content;
+ }
+
+ yield event;
+ }
+
+ if (fullContent) {
+ const assistantMessage: Message = {
+ id: randomUUID(),
+ role: "assistant",
+ content: fullContent,
+ createdAt: new Date(),
+ };
+ await self.threadStore.addMessage(
+ thread.id,
+ userId,
+ assistantMessage,
+ );
+ }
+
+ yield { type: "status" as const, status: "complete" as const };
+ } catch (error) {
+ if (signal.aborted) return;
+ logger.error("Agent chat error: %O", error);
+ throw error;
+ } finally {
+ self.activeStreams.delete(requestId);
+ }
+ },
+ {
+ ...agentStreamDefaults,
+ stream: {
+ ...agentStreamDefaults.stream,
+ streamId: requestId,
+ },
+ },
+ );
+ }
+
+ private async _handleCancel(
+ req: express.Request,
+ res: express.Response,
+ ): Promise {
+ const { streamId } = req.body as { streamId?: string };
+ if (!streamId) {
+ res.status(400).json({ error: "streamId is required" });
+ return;
+ }
+ const controller = this.activeStreams.get(streamId);
+ if (controller) {
+ controller.abort("Cancelled by user");
+ this.activeStreams.delete(streamId);
+ }
+ res.json({ cancelled: true });
+ }
+
+ private async _handleListThreads(
+ req: express.Request,
+ res: express.Response,
+ ): Promise {
+ const userId = this.resolveUserId(req);
+ const threads = await this.threadStore.list(userId);
+ res.json({ threads });
+ }
+
+ private async _handleGetThread(
+ req: express.Request,
+ res: express.Response,
+ ): Promise {
+ const userId = this.resolveUserId(req);
+ const thread = await this.threadStore.get(req.params.threadId, userId);
+ if (!thread) {
+ res.status(404).json({ error: "Thread not found" });
+ return;
+ }
+ res.json(thread);
+ }
+
+ private async _handleDeleteThread(
+ req: express.Request,
+ res: express.Response,
+ ): Promise {
+ const userId = this.resolveUserId(req);
+ const deleted = await this.threadStore.delete(req.params.threadId, userId);
+ if (!deleted) {
+ res.status(404).json({ error: "Thread not found" });
+ return;
+ }
+ res.json({ deleted: true });
+ }
+
+ private async _handleListTools(
+ _req: express.Request,
+ res: express.Response,
+ ): Promise {
+ res.json({ tools: this.getAllToolDefinitions() });
+ }
+
+ private resolveAgent(name?: string): RegisteredAgent | null {
+ if (name) return this.agents.get(name) ?? null;
+ if (this.defaultAgentName) {
+ return this.agents.get(this.defaultAgentName) ?? null;
+ }
+ const first = this.agents.values().next();
+ return first.done ? null : first.value;
+ }
+
+ private getAllToolDefinitions(): AgentToolDefinition[] {
+ return Array.from(this.toolIndex.values()).map((e) => e.def);
+ }
+
+ exports() {
+ return {
+ registerAgent: (name: string, adapter: AgentAdapter) => {
+ this.agents.set(name, { name, adapter });
+ if (!this.defaultAgentName) {
+ this.defaultAgentName = name;
+ }
+ },
+ registerTool: (
+ pluginName: string,
+ tool: AgentToolDefinition,
+ provider: ToolProvider & { asUser(req: any): any },
+ ) => {
+ const qualifiedName = `${pluginName}.${tool.name}`;
+ this.toolIndex.set(qualifiedName, {
+ plugin: provider,
+ def: { ...tool, name: qualifiedName },
+ localName: tool.name,
+ });
+ },
+ getTools: () => this.getAllToolDefinitions(),
+ getThreads: (userId: string) => this.threadStore.list(userId),
+ };
+ }
+}
+
+/**
+ * @internal
+ */
+export const agent = toPlugin(AgentPlugin);
diff --git a/packages/appkit/src/plugins/agent/defaults.ts b/packages/appkit/src/plugins/agent/defaults.ts
new file mode 100644
index 00000000..4da11bef
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/defaults.ts
@@ -0,0 +1,12 @@
+import type { StreamExecutionSettings } from "shared";
+
+export const agentStreamDefaults: StreamExecutionSettings = {
+ default: {
+ cache: { enabled: false },
+ retry: { enabled: false },
+ timeout: 300_000,
+ },
+ stream: {
+ bufferSize: 200,
+ },
+};
diff --git a/packages/appkit/src/plugins/agent/index.ts b/packages/appkit/src/plugins/agent/index.ts
new file mode 100644
index 00000000..66b07e47
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/index.ts
@@ -0,0 +1,3 @@
+export { agent } from "./agent";
+;
+;
diff --git a/packages/appkit/src/plugins/agent/manifest.json b/packages/appkit/src/plugins/agent/manifest.json
new file mode 100644
index 00000000..d73b94ea
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/manifest.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json",
+ "name": "agent",
+ "displayName": "Agent Plugin",
+ "description": "Framework-agnostic AI agent with auto-tool-discovery from all registered plugins",
+ "resources": {
+ "required": [],
+ "optional": []
+ }
+}
diff --git a/packages/appkit/src/plugins/agent/tests/agent.test.ts b/packages/appkit/src/plugins/agent/tests/agent.test.ts
new file mode 100644
index 00000000..f67a10e1
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/tests/agent.test.ts
@@ -0,0 +1,149 @@
+import { createMockRouter, setupDatabricksEnv } from "@tools/test-helpers";
+import type {
+ AgentAdapter,
+ AgentEvent,
+ AgentToolDefinition,
+ ToolProvider,
+} from "shared";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { AgentPlugin } from "../agent";
+
+vi.mock("../../../cache", () => ({
+ CacheManager: {
+ getInstanceSync: vi.fn(() => ({
+ get: vi.fn(),
+ set: vi.fn(),
+ delete: vi.fn(),
+ getOrExecute: vi.fn(),
+ generateKey: vi.fn(),
+ })),
+ },
+}));
+
+vi.mock("../../../telemetry", () => ({
+ TelemetryManager: {
+ getProvider: vi.fn(() => ({
+ getTracer: vi.fn(),
+ getMeter: vi.fn(),
+ getLogger: vi.fn(),
+ emit: vi.fn(),
+ startActiveSpan: vi.fn(),
+ registerInstrumentations: vi.fn(),
+ })),
+ },
+ normalizeTelemetryOptions: vi.fn(() => ({
+ traces: false,
+ metrics: false,
+ logs: false,
+ })),
+}));
+
+function createMockToolProvider(
+ tools: AgentToolDefinition[],
+): ToolProvider & { asUser: any } {
+ return {
+ getAgentTools: () => tools,
+ executeAgentTool: vi.fn().mockResolvedValue({ result: "ok" }),
+ asUser: vi.fn().mockReturnThis(),
+ };
+}
+
+async function* mockAdapterRun(): AsyncGenerator {
+ yield { type: "message_delta", content: "Hello " };
+ yield { type: "message_delta", content: "world" };
+}
+
+function createMockAdapter(): AgentAdapter {
+ return {
+ run: vi.fn().mockReturnValue(mockAdapterRun()),
+ };
+}
+
+describe("AgentPlugin", () => {
+ beforeEach(() => {
+ setupDatabricksEnv();
+ });
+
+ test("collectTools discovers ToolProvider plugins", async () => {
+ const mockProvider = createMockToolProvider([
+ {
+ name: "query",
+ description: "Run a query",
+ parameters: { type: "object", properties: {} },
+ },
+ ]);
+
+ const plugin = new AgentPlugin({
+ name: "agent",
+ plugins: { analytics: mockProvider },
+ });
+
+ await plugin.setup();
+
+ const exports = plugin.exports();
+ const tools = exports.getTools();
+
+ expect(tools).toHaveLength(1);
+ expect(tools[0].name).toBe("analytics.query");
+ });
+
+ test("skips non-ToolProvider plugins", async () => {
+ const plugin = new AgentPlugin({
+ name: "agent",
+ plugins: {
+ server: { name: "server" },
+ analytics: createMockToolProvider([
+ { name: "query", description: "q", parameters: { type: "object" } },
+ ]),
+ },
+ });
+
+ await plugin.setup();
+ const tools = plugin.exports().getTools();
+ expect(tools).toHaveLength(1);
+ });
+
+ test("registerAgent and resolveAgent", () => {
+ const plugin = new AgentPlugin({ name: "agent" });
+ const adapter = createMockAdapter();
+
+ plugin.exports().registerAgent("assistant", adapter);
+
+ // The first registered agent becomes the default
+ const tools = plugin.exports().getTools();
+ expect(tools).toEqual([]);
+ });
+
+ test("injectRoutes registers all 6 routes", () => {
+ const plugin = new AgentPlugin({ name: "agent" });
+ const { router, handlers } = createMockRouter();
+
+ plugin.injectRoutes(router);
+
+ expect(handlers["POST:/chat"]).toBeDefined();
+ expect(handlers["POST:/cancel"]).toBeDefined();
+ expect(handlers["GET:/threads"]).toBeDefined();
+ expect(handlers["GET:/threads/:threadId"]).toBeDefined();
+ expect(handlers["DELETE:/threads/:threadId"]).toBeDefined();
+ expect(handlers["GET:/tools"]).toBeDefined();
+ });
+
+ test("exports().registerTool adds external tools", () => {
+ const plugin = new AgentPlugin({ name: "agent" });
+ const provider = createMockToolProvider([]);
+
+ plugin.exports().registerTool(
+ "custom",
+ {
+ name: "myTool",
+ description: "A custom tool",
+ parameters: { type: "object" },
+ },
+ provider,
+ );
+
+ const tools = plugin.exports().getTools();
+ expect(tools).toHaveLength(1);
+ expect(tools[0].name).toBe("custom.myTool");
+ });
+});
diff --git a/packages/appkit/src/plugins/agent/tests/thread-store.test.ts b/packages/appkit/src/plugins/agent/tests/thread-store.test.ts
new file mode 100644
index 00000000..ed4f70ba
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/tests/thread-store.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, test } from "vitest";
+import { InMemoryThreadStore } from "../thread-store";
+
+describe("InMemoryThreadStore", () => {
+ test("create() returns a new thread with the given userId", async () => {
+ const store = new InMemoryThreadStore();
+ const thread = await store.create("user-1");
+
+ expect(thread.id).toBeDefined();
+ expect(thread.userId).toBe("user-1");
+ expect(thread.messages).toEqual([]);
+ expect(thread.createdAt).toBeInstanceOf(Date);
+ expect(thread.updatedAt).toBeInstanceOf(Date);
+ });
+
+ test("get() returns the thread for the correct user", async () => {
+ const store = new InMemoryThreadStore();
+ const thread = await store.create("user-1");
+
+ const retrieved = await store.get(thread.id, "user-1");
+ expect(retrieved).toEqual(thread);
+ });
+
+ test("get() returns null for wrong user", async () => {
+ const store = new InMemoryThreadStore();
+ const thread = await store.create("user-1");
+
+ const retrieved = await store.get(thread.id, "user-2");
+ expect(retrieved).toBeNull();
+ });
+
+ test("get() returns null for non-existent thread", async () => {
+ const store = new InMemoryThreadStore();
+ const retrieved = await store.get("non-existent", "user-1");
+ expect(retrieved).toBeNull();
+ });
+
+ test("list() returns threads sorted by updatedAt desc", async () => {
+ const store = new InMemoryThreadStore();
+ const t1 = await store.create("user-1");
+ const t2 = await store.create("user-1");
+
+ // Make t1 more recently updated
+ await store.addMessage(t1.id, "user-1", {
+ id: "msg-1",
+ role: "user",
+ content: "hello",
+ createdAt: new Date(),
+ });
+
+ const threads = await store.list("user-1");
+ expect(threads).toHaveLength(2);
+ expect(threads[0].id).toBe(t1.id);
+ expect(threads[1].id).toBe(t2.id);
+ });
+
+ test("list() returns empty for unknown user", async () => {
+ const store = new InMemoryThreadStore();
+ await store.create("user-1");
+
+ const threads = await store.list("user-2");
+ expect(threads).toEqual([]);
+ });
+
+ test("addMessage() appends to thread and updates timestamp", async () => {
+ const store = new InMemoryThreadStore();
+ const thread = await store.create("user-1");
+ const originalUpdatedAt = thread.updatedAt;
+
+ // Small delay to ensure timestamp differs
+ await new Promise((r) => setTimeout(r, 5));
+
+ await store.addMessage(thread.id, "user-1", {
+ id: "msg-1",
+ role: "user",
+ content: "hello",
+ createdAt: new Date(),
+ });
+
+ const updated = await store.get(thread.id, "user-1");
+ expect(updated?.messages).toHaveLength(1);
+ expect(updated?.messages[0].content).toBe("hello");
+ expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual(
+ originalUpdatedAt.getTime(),
+ );
+ });
+
+ test("addMessage() throws for non-existent thread", async () => {
+ const store = new InMemoryThreadStore();
+
+ await expect(
+ store.addMessage("non-existent", "user-1", {
+ id: "msg-1",
+ role: "user",
+ content: "hello",
+ createdAt: new Date(),
+ }),
+ ).rejects.toThrow("Thread non-existent not found");
+ });
+
+ test("delete() removes a thread and returns true", async () => {
+ const store = new InMemoryThreadStore();
+ const thread = await store.create("user-1");
+
+ const deleted = await store.delete(thread.id, "user-1");
+ expect(deleted).toBe(true);
+
+ const retrieved = await store.get(thread.id, "user-1");
+ expect(retrieved).toBeNull();
+ });
+
+ test("delete() returns false for non-existent thread", async () => {
+ const store = new InMemoryThreadStore();
+ const deleted = await store.delete("non-existent", "user-1");
+ expect(deleted).toBe(false);
+ });
+
+ test("delete() returns false for wrong user", async () => {
+ const store = new InMemoryThreadStore();
+ const thread = await store.create("user-1");
+
+ const deleted = await store.delete(thread.id, "user-2");
+ expect(deleted).toBe(false);
+ });
+
+ test("threads are isolated per user", async () => {
+ const store = new InMemoryThreadStore();
+ await store.create("user-1");
+ await store.create("user-1");
+ await store.create("user-2");
+
+ const user1Threads = await store.list("user-1");
+ const user2Threads = await store.list("user-2");
+
+ expect(user1Threads).toHaveLength(2);
+ expect(user2Threads).toHaveLength(1);
+ });
+});
diff --git a/packages/appkit/src/plugins/agent/thread-store.ts b/packages/appkit/src/plugins/agent/thread-store.ts
new file mode 100644
index 00000000..f3ca0599
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/thread-store.ts
@@ -0,0 +1,59 @@
+import { randomUUID } from "node:crypto";
+import type { Message, Thread, ThreadStore } from "shared";
+
+/**
+ * In-memory thread store backed by a nested Map.
+ *
+ * Outer key: userId, inner key: threadId.
+ * Suitable for development and single-instance deployments.
+ */
+export class InMemoryThreadStore implements ThreadStore {
+ private store = new Map>();
+
+ async create(userId: string): Promise {
+ const now = new Date();
+ const thread: Thread = {
+ id: randomUUID(),
+ userId,
+ messages: [],
+ createdAt: now,
+ updatedAt: now,
+ };
+ this.userMap(userId).set(thread.id, thread);
+ return thread;
+ }
+
+ async get(threadId: string, userId: string): Promise {
+ return this.userMap(userId).get(threadId) ?? null;
+ }
+
+ async list(userId: string): Promise {
+ return Array.from(this.userMap(userId).values()).sort(
+ (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(),
+ );
+ }
+
+ async addMessage(
+ threadId: string,
+ userId: string,
+ message: Message,
+ ): Promise {
+ const thread = this.userMap(userId).get(threadId);
+ if (!thread) throw new Error(`Thread ${threadId} not found`);
+ thread.messages.push(message);
+ thread.updatedAt = new Date();
+ }
+
+ async delete(threadId: string, userId: string): Promise {
+ return this.userMap(userId).delete(threadId);
+ }
+
+ private userMap(userId: string): Map {
+ let map = this.store.get(userId);
+ if (!map) {
+ map = new Map();
+ this.store.set(userId, map);
+ }
+ return map;
+ }
+}
diff --git a/packages/appkit/src/plugins/agent/types.ts b/packages/appkit/src/plugins/agent/types.ts
new file mode 100644
index 00000000..2934f558
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/types.ts
@@ -0,0 +1,34 @@
+import type {
+ AgentAdapter,
+ AgentToolDefinition,
+ BasePluginConfig,
+ ThreadStore,
+ ToolProvider,
+} from "shared";
+
+export interface AgentPluginConfig extends BasePluginConfig {
+ agents?: Record>;
+ defaultAgent?: string;
+ threadStore?: ThreadStore;
+ plugins?: Record;
+}
+
+export interface ToolEntry {
+ plugin: ToolProvider & { asUser(req: any): any };
+ def: AgentToolDefinition;
+ localName: string;
+}
+
+export type RegisteredAgent = {
+ name: string;
+ adapter: AgentAdapter;
+};
+
+export type {
+ AgentAdapter,
+
+
+
+ AgentToolDefinition,
+ ToolProvider,
+} from "shared";
diff --git a/packages/appkit/src/plugins/analytics/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts
index 86b60986..db38b556 100644
--- a/packages/appkit/src/plugins/analytics/analytics.ts
+++ b/packages/appkit/src/plugins/analytics/analytics.ts
@@ -1,10 +1,12 @@
import type { WorkspaceClient } from "@databricks/sdk-experimental";
import type express from "express";
import type {
+ AgentToolDefinition,
IAppRouter,
PluginExecuteConfig,
SQLTypeMarker,
StreamExecutionSettings,
+ ToolProvider,
} from "shared";
import { SQLWarehouseConnector } from "../../connectors";
import {
@@ -26,7 +28,7 @@ import type {
const logger = createLogger("analytics");
-export class AnalyticsPlugin extends Plugin {
+export class AnalyticsPlugin extends Plugin implements ToolProvider {
/** Plugin manifest declaring metadata and resource requirements */
static manifest = manifest as PluginManifest<"analytics">;
@@ -267,6 +269,40 @@ export class AnalyticsPlugin extends Plugin {
this.streamManager.abortAll();
}
+ getAgentTools(): AgentToolDefinition[] {
+ return [
+ {
+ name: "query",
+ description:
+ "Execute a SQL query against the Databricks SQL warehouse. Returns the query results as JSON.",
+ parameters: {
+ type: "object",
+ properties: {
+ query: {
+ type: "string",
+ description: "The SQL query to execute",
+ },
+ },
+ required: ["query"],
+ },
+ annotations: {
+ readOnly: true,
+ requiresUserContext: true,
+ },
+ },
+ ];
+ }
+
+ async executeAgentTool(
+ name: string,
+ args: unknown,
+ signal?: AbortSignal,
+ ): Promise {
+ if (name !== "query") throw new Error(`Unknown tool: ${name}`);
+ const { query } = args as { query: string };
+ return this.query(query, undefined, undefined, signal);
+ }
+
/**
* Returns the public exports for the analytics plugin.
* Note: `asUser()` is automatically added by AppKit.
diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts
index e10f5d42..11ead497 100644
--- a/packages/appkit/src/plugins/files/plugin.ts
+++ b/packages/appkit/src/plugins/files/plugin.ts
@@ -1,7 +1,12 @@
import { Readable } from "node:stream";
import { ApiError } from "@databricks/sdk-experimental";
import type express from "express";
-import type { IAppRouter, PluginExecutionSettings } from "shared";
+import type {
+ AgentToolDefinition,
+ IAppRouter,
+ PluginExecutionSettings,
+ ToolProvider,
+} from "shared";
import {
contentTypeFromPath,
FilesConnector,
@@ -33,7 +38,7 @@ import type {
const logger = createLogger("files");
-export class FilesPlugin extends Plugin {
+export class FilesPlugin extends Plugin implements ToolProvider {
name = "files";
/** Plugin manifest declaring metadata and resource requirements. */
@@ -909,6 +914,137 @@ export class FilesPlugin extends Plugin {
}
}
+ getAgentTools(): AgentToolDefinition[] {
+ const tools: AgentToolDefinition[] = [];
+
+ for (const volumeKey of this.volumeKeys) {
+ tools.push({
+ name: `${volumeKey}.list`,
+ description: `List files and directories in the "${volumeKey}" volume`,
+ parameters: {
+ type: "object",
+ properties: {
+ path: {
+ type: "string",
+ description:
+ "Directory path to list (optional, defaults to root)",
+ },
+ },
+ },
+ annotations: { readOnly: true, requiresUserContext: true },
+ });
+
+ tools.push({
+ name: `${volumeKey}.read`,
+ description: `Read a text file from the "${volumeKey}" volume`,
+ parameters: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "File path to read" },
+ },
+ required: ["path"],
+ },
+ annotations: { readOnly: true, requiresUserContext: true },
+ });
+
+ tools.push({
+ name: `${volumeKey}.exists`,
+ description: `Check if a file or directory exists in the "${volumeKey}" volume`,
+ parameters: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path to check" },
+ },
+ required: ["path"],
+ },
+ annotations: { readOnly: true, requiresUserContext: true },
+ });
+
+ tools.push({
+ name: `${volumeKey}.metadata`,
+ description: `Get metadata (size, type, last modified) for a file in the "${volumeKey}" volume`,
+ parameters: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "File path" },
+ },
+ required: ["path"],
+ },
+ annotations: { readOnly: true, requiresUserContext: true },
+ });
+
+ tools.push({
+ name: `${volumeKey}.upload`,
+ description: `Upload a text file to the "${volumeKey}" volume`,
+ parameters: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Destination file path" },
+ contents: {
+ type: "string",
+ description: "File contents as a string",
+ },
+ overwrite: {
+ type: "boolean",
+ description: "Whether to overwrite existing file",
+ },
+ },
+ required: ["path", "contents"],
+ },
+ annotations: { destructive: true, requiresUserContext: true },
+ });
+
+ tools.push({
+ name: `${volumeKey}.delete`,
+ description: `Delete a file from the "${volumeKey}" volume`,
+ parameters: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "File path to delete" },
+ },
+ required: ["path"],
+ },
+ annotations: { destructive: true, requiresUserContext: true },
+ });
+ }
+
+ return tools;
+ }
+
+ async executeAgentTool(name: string, args: unknown): Promise {
+ const dotIdx = name.indexOf(".");
+ if (dotIdx === -1) throw new Error(`Invalid tool name: ${name}`);
+
+ const volumeKey = name.slice(0, dotIdx);
+ const method = name.slice(dotIdx + 1);
+
+ if (!this.volumeKeys.includes(volumeKey)) {
+ throw new Error(`Unknown volume: ${volumeKey}`);
+ }
+
+ const api = this.createVolumeAPI(volumeKey);
+ const params = args as Record;
+
+ switch (method) {
+ case "list":
+ return api.list(params.path);
+ case "read":
+ return api.read(params.path);
+ case "exists":
+ return api.exists(params.path);
+ case "metadata":
+ return api.metadata(params.path);
+ case "upload":
+ return api.upload(params.path, params.contents, {
+ overwrite: params.overwrite,
+ });
+ case "delete":
+ return api.delete(params.path);
+ default:
+ throw new Error(`Unknown method: ${method}`);
+ }
+ }
+
private inflightWrites = 0;
private trackWrite(fn: () => Promise): Promise {
diff --git a/packages/appkit/src/plugins/genie/genie.ts b/packages/appkit/src/plugins/genie/genie.ts
index 2ca348b4..faf7953e 100644
--- a/packages/appkit/src/plugins/genie/genie.ts
+++ b/packages/appkit/src/plugins/genie/genie.ts
@@ -1,6 +1,11 @@
import { randomUUID } from "node:crypto";
import type express from "express";
-import type { IAppRouter, StreamExecutionSettings } from "shared";
+import type {
+ AgentToolDefinition,
+ IAppRouter,
+ StreamExecutionSettings,
+ ToolProvider,
+} from "shared";
import { GenieConnector } from "../../connectors";
import { getWorkspaceClient } from "../../context";
import { createLogger } from "../../logging";
@@ -17,7 +22,7 @@ import type {
const logger = createLogger("genie");
-export class GeniePlugin extends Plugin {
+export class GeniePlugin extends Plugin implements ToolProvider {
static manifest = manifest as PluginManifest<"genie">;
protected static description =
@@ -225,6 +230,90 @@ export class GeniePlugin extends Plugin {
this.streamManager.abortAll();
}
+ getAgentTools(): AgentToolDefinition[] {
+ const spaces = Object.keys(this.config.spaces ?? {});
+ const tools: AgentToolDefinition[] = [];
+
+ for (const alias of spaces) {
+ tools.push({
+ name: `${alias}.sendMessage`,
+ description: `Send a natural language question to the Genie space "${alias}" and get data analysis results`,
+ parameters: {
+ type: "object",
+ properties: {
+ content: {
+ type: "string",
+ description: "The natural language question to ask",
+ },
+ conversationId: {
+ type: "string",
+ description:
+ "Optional conversation ID to continue an existing conversation",
+ },
+ },
+ required: ["content"],
+ },
+ annotations: {
+ requiresUserContext: true,
+ },
+ });
+
+ tools.push({
+ name: `${alias}.getConversation`,
+ description: `Retrieve the conversation history from the Genie space "${alias}"`,
+ parameters: {
+ type: "object",
+ properties: {
+ conversationId: {
+ type: "string",
+ description: "The conversation ID to retrieve",
+ },
+ },
+ required: ["conversationId"],
+ },
+ annotations: {
+ readOnly: true,
+ requiresUserContext: true,
+ },
+ });
+ }
+
+ return tools;
+ }
+
+ async executeAgentTool(name: string, args: unknown): Promise {
+ const parts = name.split(".");
+ if (parts.length !== 2) throw new Error(`Invalid tool name: ${name}`);
+
+ const [alias, method] = parts;
+
+ switch (method) {
+ case "sendMessage": {
+ const { content, conversationId } = args as {
+ content: string;
+ conversationId?: string;
+ };
+ const events: GenieStreamEvent[] = [];
+ for await (const event of this.sendMessage(
+ alias,
+ content,
+ conversationId,
+ )) {
+ events.push(event);
+ }
+ return events;
+ }
+
+ case "getConversation": {
+ const { conversationId } = args as { conversationId: string };
+ return this.getConversation(alias, conversationId);
+ }
+
+ default:
+ throw new Error(`Unknown method: ${method}`);
+ }
+ }
+
exports() {
return {
sendMessage: this.sendMessage,
diff --git a/packages/appkit/src/plugins/index.ts b/packages/appkit/src/plugins/index.ts
index 7caa040f..aa1df929 100644
--- a/packages/appkit/src/plugins/index.ts
+++ b/packages/appkit/src/plugins/index.ts
@@ -1,3 +1,4 @@
+export * from "./agent";
export * from "./analytics";
export * from "./files";
export * from "./genie";
diff --git a/packages/appkit/src/plugins/lakebase/lakebase.ts b/packages/appkit/src/plugins/lakebase/lakebase.ts
index 3071d539..cffe12a2 100644
--- a/packages/appkit/src/plugins/lakebase/lakebase.ts
+++ b/packages/appkit/src/plugins/lakebase/lakebase.ts
@@ -1,4 +1,5 @@
import type { Pool, QueryResult, QueryResultRow } from "pg";
+import type { AgentToolDefinition, ToolProvider } from "shared";
import {
createLakebasePool,
getLakebaseOrmConfig,
@@ -30,7 +31,7 @@ const logger = createLogger("lakebase");
* const result = await AppKit.lakebase.query("SELECT * FROM users WHERE id = $1", [userId]);
* ```
*/
-class LakebasePlugin extends Plugin {
+class LakebasePlugin extends Plugin implements ToolProvider {
/** Plugin manifest declaring metadata and resource requirements */
static manifest = manifest as PluginManifest<"lakebase">;
@@ -94,6 +95,44 @@ class LakebasePlugin extends Plugin {
}
}
+ getAgentTools(): AgentToolDefinition[] {
+ return [
+ {
+ name: "query",
+ description:
+ "Execute a parameterized SQL query against the Lakebase PostgreSQL database. Use $1, $2, etc. as placeholders and pass values separately.",
+ parameters: {
+ type: "object",
+ properties: {
+ text: {
+ type: "string",
+ description:
+ "SQL query string with $1, $2, ... placeholders for parameters",
+ },
+ values: {
+ type: "array",
+ items: {},
+ description: "Parameter values corresponding to placeholders",
+ },
+ },
+ required: ["text"],
+ },
+ annotations: {
+ readOnly: false,
+ destructive: false,
+ idempotent: false,
+ },
+ },
+ ];
+ }
+
+ async executeAgentTool(name: string, args: unknown): Promise {
+ if (name !== "query") throw new Error(`Unknown tool: ${name}`);
+ const { text, values } = args as { text: string; values?: unknown[] };
+ const result = await this.query(text, values);
+ return result.rows;
+ }
+
/**
* Returns the plugin's public API, accessible via `AppKit.lakebase`.
*
diff --git a/packages/appkit/tsdown.config.ts b/packages/appkit/tsdown.config.ts
index 77cda46f..c086bb69 100644
--- a/packages/appkit/tsdown.config.ts
+++ b/packages/appkit/tsdown.config.ts
@@ -4,7 +4,12 @@ export default defineConfig([
{
publint: true,
name: "@databricks/appkit",
- entry: "src/index.ts",
+ entry: [
+ "src/index.ts",
+ "src/agents/vercel-ai.ts",
+ "src/agents/langchain.ts",
+ "src/agents/databricks.ts",
+ ],
outDir: "dist",
hash: false,
format: "esm",
diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts
new file mode 100644
index 00000000..545c4616
--- /dev/null
+++ b/packages/shared/src/agent.ts
@@ -0,0 +1,112 @@
+import type { JSONSchema7 } from "json-schema";
+
+// ---------------------------------------------------------------------------
+// Tool definitions
+// ---------------------------------------------------------------------------
+
+export interface ToolAnnotations {
+ readOnly?: boolean;
+ destructive?: boolean;
+ idempotent?: boolean;
+ requiresUserContext?: boolean;
+}
+
+export interface AgentToolDefinition {
+ name: string;
+ description: string;
+ parameters: JSONSchema7;
+ annotations?: ToolAnnotations;
+}
+
+export interface ToolProvider {
+ getAgentTools(): AgentToolDefinition[];
+ executeAgentTool(
+ name: string,
+ args: unknown,
+ signal?: AbortSignal,
+ ): Promise;
+}
+
+// ---------------------------------------------------------------------------
+// Messages & threads
+// ---------------------------------------------------------------------------
+
+export interface Message {
+ id: string;
+ role: "user" | "assistant" | "system" | "tool";
+ content: string;
+ toolCallId?: string;
+ toolCalls?: ToolCall[];
+ createdAt: Date;
+}
+
+export interface ToolCall {
+ id: string;
+ name: string;
+ args: unknown;
+}
+
+export interface Thread {
+ id: string;
+ userId: string;
+ messages: Message[];
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+// ---------------------------------------------------------------------------
+// Thread store
+// ---------------------------------------------------------------------------
+
+export interface ThreadStore {
+ create(userId: string): Promise;
+ get(threadId: string, userId: string): Promise;
+ list(userId: string): Promise;
+ addMessage(threadId: string, userId: string, message: Message): Promise;
+ delete(threadId: string, userId: string): Promise;
+}
+
+// ---------------------------------------------------------------------------
+// Agent events (SSE protocol)
+// ---------------------------------------------------------------------------
+
+export type AgentEvent =
+ | { type: "message_delta"; content: string }
+ | { type: "message"; content: string }
+ | { type: "tool_call"; callId: string; name: string; args: unknown }
+ | {
+ type: "tool_result";
+ callId: string;
+ result: unknown;
+ error?: string;
+ }
+ | { type: "thinking"; content: string }
+ | {
+ type: "status";
+ status: "running" | "waiting" | "complete" | "error";
+ error?: string;
+ }
+ | { type: "metadata"; data: Record };
+
+// ---------------------------------------------------------------------------
+// Adapter contract
+// ---------------------------------------------------------------------------
+
+export interface AgentInput {
+ messages: Message[];
+ tools: AgentToolDefinition[];
+ threadId: string;
+ signal?: AbortSignal;
+}
+
+export interface AgentRunContext {
+ executeTool: (name: string, args: unknown) => Promise;
+ signal?: AbortSignal;
+}
+
+export interface AgentAdapter {
+ run(
+ input: AgentInput,
+ context: AgentRunContext,
+ ): AsyncGenerator;
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 627d70d6..9829729a 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -1,3 +1,4 @@
+export * from "./agent";
export * from "./cache";
export * from "./execute";
export * from "./genie";
diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json
index cf60a8af..a9ca281d 100644
--- a/template/appkit.plugins.json
+++ b/template/appkit.plugins.json
@@ -2,6 +2,16 @@
"$schema": "https://databricks.github.io/appkit/schemas/template-plugins.schema.json",
"version": "1.0",
"plugins": {
+ "agent": {
+ "name": "agent",
+ "displayName": "Agent Plugin",
+ "description": "Framework-agnostic AI agent with auto-tool-discovery from all registered plugins",
+ "package": "@databricks/appkit",
+ "resources": {
+ "required": [],
+ "optional": []
+ }
+ },
"analytics": {
"name": "analytics",
"displayName": "Analytics Plugin",
From 976c01881707ee3e5e2ef2346edd9eaf35643863 Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Thu, 26 Mar 2026 18:08:34 +0100
Subject: [PATCH 02/11] chore: fixup
---
apps/agent-app/.gitignore | 3 +
apps/agent-app/index.html | 12 +
apps/agent-app/package.json | 38 ++
apps/agent-app/postcss.config.js | 6 +
apps/agent-app/server.ts | 21 +
apps/agent-app/src/App.css | 362 +++++++++++
apps/agent-app/src/App.tsx | 264 ++++++++
.../src/components/theme-selector.tsx | 135 ++++
apps/agent-app/src/index.css | 1 +
apps/agent-app/src/main.tsx | 15 +
apps/agent-app/tailwind.config.ts | 11 +
apps/agent-app/tsconfig.app.json | 24 +
apps/agent-app/tsconfig.json | 7 +
apps/agent-app/tsconfig.node.json | 22 +
apps/agent-app/vite.config.ts | 26 +
docs/docs/api/appkit/Function.createAgent.md | 52 ++
docs/docs/api/appkit/Interface.AgentHandle.md | 66 ++
.../api/appkit/Interface.CreateAgentConfig.md | 95 +++
docs/docs/api/appkit/index.md | 3 +
docs/docs/api/appkit/typedoc-sidebar.ts | 15 +
package.json | 1 +
packages/appkit/src/agents/databricks.ts | 1 -
packages/appkit/src/agents/langchain.ts | 1 -
packages/appkit/src/core/create-agent.ts | 130 ++++
.../src/core/tests/create-agent.test.ts | 206 ++++++
packages/appkit/src/index.ts | 5 +
packages/appkit/src/plugins/agent/index.ts | 4 +-
packages/appkit/src/plugins/agent/types.ts | 3 -
pnpm-lock.yaml | 595 +++++++++++++++++-
29 files changed, 2087 insertions(+), 37 deletions(-)
create mode 100644 apps/agent-app/.gitignore
create mode 100644 apps/agent-app/index.html
create mode 100644 apps/agent-app/package.json
create mode 100644 apps/agent-app/postcss.config.js
create mode 100644 apps/agent-app/server.ts
create mode 100644 apps/agent-app/src/App.css
create mode 100644 apps/agent-app/src/App.tsx
create mode 100644 apps/agent-app/src/components/theme-selector.tsx
create mode 100644 apps/agent-app/src/index.css
create mode 100644 apps/agent-app/src/main.tsx
create mode 100644 apps/agent-app/tailwind.config.ts
create mode 100644 apps/agent-app/tsconfig.app.json
create mode 100644 apps/agent-app/tsconfig.json
create mode 100644 apps/agent-app/tsconfig.node.json
create mode 100644 apps/agent-app/vite.config.ts
create mode 100644 docs/docs/api/appkit/Function.createAgent.md
create mode 100644 docs/docs/api/appkit/Interface.AgentHandle.md
create mode 100644 docs/docs/api/appkit/Interface.CreateAgentConfig.md
create mode 100644 packages/appkit/src/core/create-agent.ts
create mode 100644 packages/appkit/src/core/tests/create-agent.test.ts
diff --git a/apps/agent-app/.gitignore b/apps/agent-app/.gitignore
new file mode 100644
index 00000000..9c97bbd4
--- /dev/null
+++ b/apps/agent-app/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+dist
+.env
diff --git a/apps/agent-app/index.html b/apps/agent-app/index.html
new file mode 100644
index 00000000..80e54faf
--- /dev/null
+++ b/apps/agent-app/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ AppKit Agent
+
+
+
+
+
+
diff --git a/apps/agent-app/package.json b/apps/agent-app/package.json
new file mode 100644
index 00000000..40f0905d
--- /dev/null
+++ b/apps/agent-app/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "agent-app",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "NODE_ENV=development tsx watch server.ts",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@databricks/appkit": "workspace:*",
+ "@databricks/appkit-ui": "workspace:*",
+ "@databricks/sdk-experimental": "^0.16.0",
+ "dotenv": "^16.6.1",
+ "lucide-react": "^0.511.0",
+ "react": "19.2.0",
+ "react-dom": "19.2.0",
+ "marked": "^15.0.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "4.1.17",
+ "@types/node": "24.10.1",
+ "@types/react": "19.2.7",
+ "@types/react-dom": "19.2.3",
+ "@vitejs/plugin-react": "5.1.1",
+ "autoprefixer": "10.4.21",
+ "postcss": "8.5.6",
+ "tailwindcss": "4.1.17",
+ "tailwindcss-animate": "1.0.7",
+ "tsx": "4.20.6",
+ "typescript": "5.9.3",
+ "vite": "npm:rolldown-vite@7.1.14"
+ },
+ "overrides": {
+ "vite": "npm:rolldown-vite@7.1.14"
+ }
+}
diff --git a/apps/agent-app/postcss.config.js b/apps/agent-app/postcss.config.js
new file mode 100644
index 00000000..f69c5d41
--- /dev/null
+++ b/apps/agent-app/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ autoprefixer: {},
+ },
+};
diff --git a/apps/agent-app/server.ts b/apps/agent-app/server.ts
new file mode 100644
index 00000000..a99a7d39
--- /dev/null
+++ b/apps/agent-app/server.ts
@@ -0,0 +1,21 @@
+import { analytics, createAgent, files } from "@databricks/appkit";
+import { DatabricksAdapter } from "@databricks/appkit/agents/databricks";
+import { WorkspaceClient } from "@databricks/sdk-experimental";
+
+const endpointName =
+ process.env.DATABRICKS_AGENT_ENDPOINT ?? "databricks-claude-sonnet-4-5";
+
+createAgent({
+ plugins: [analytics(), files()],
+ adapter: DatabricksAdapter.fromServingEndpoint({
+ workspaceClient: new WorkspaceClient({}),
+ endpointName,
+ systemPrompt:
+ "You are a helpful data assistant. Use the available tools to query data and help users with their analysis.",
+ }),
+ port: 8003,
+}).then((agent) => {
+ const tools = agent.getTools();
+ console.log(`Agent running with ${tools.length} tools`);
+ console.log("Tools:", tools.map((t) => t.name).join(", "));
+});
diff --git a/apps/agent-app/src/App.css b/apps/agent-app/src/App.css
new file mode 100644
index 00000000..1928960d
--- /dev/null
+++ b/apps/agent-app/src/App.css
@@ -0,0 +1,362 @@
+:root {
+ --bg: #fafafa;
+ --card: #ffffff;
+ --border: #e5e5e5;
+ --text: #171717;
+ --text-muted: #737373;
+ --text-faint: #a3a3a3;
+ --primary: #2563eb;
+ --primary-fg: #ffffff;
+ --muted: #f5f5f5;
+ --ring: #93c5fd;
+ --radius: 10px;
+ --font: system-ui, -apple-system, sans-serif;
+ --mono: "SF Mono", "Cascadia Code", "Fira Code", monospace;
+}
+
+:root.dark {
+ --bg: #0a0a0a;
+ --card: #171717;
+ --border: #262626;
+ --text: #fafafa;
+ --text-muted: #a3a3a3;
+ --text-faint: #525252;
+ --primary: #3b82f6;
+ --primary-fg: #ffffff;
+ --muted: #262626;
+ --ring: #1d4ed8;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: var(--font);
+ background: var(--bg);
+ color: var(--text);
+ -webkit-font-smoothing: antialiased;
+}
+
+.app {
+ min-height: 100vh;
+}
+
+.container {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 2.5rem 1.5rem;
+}
+
+.header {
+ margin-bottom: 1.5rem;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+}
+
+.header h1 {
+ font-size: 1.75rem;
+ font-weight: 700;
+ letter-spacing: -0.025em;
+}
+
+.subtitle {
+ color: var(--text-muted);
+ font-size: 0.875rem;
+ margin-top: 0.25rem;
+}
+
+.thread-id {
+ font-family: var(--mono);
+ font-size: 0.75rem;
+ opacity: 0.6;
+}
+
+.main-layout {
+ display: flex;
+ gap: 1.25rem;
+ height: 700px;
+}
+
+.chat-panel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--card);
+ min-width: 0;
+ overflow: hidden;
+}
+
+.messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 1.25rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.empty-state {
+ text-align: center;
+ padding: 5rem 1rem;
+ color: var(--text-muted);
+}
+
+.empty-title {
+ font-size: 1.1rem;
+ font-weight: 500;
+}
+
+.empty-sub {
+ font-size: 0.85rem;
+ margin-top: 0.5rem;
+ color: var(--text-faint);
+}
+
+.message-row {
+ display: flex;
+}
+
+.message-row.user {
+ justify-content: flex-end;
+}
+
+.message-row.assistant {
+ justify-content: flex-start;
+}
+
+.bubble {
+ max-width: 80%;
+ padding: 0.625rem 0.875rem;
+ border-radius: var(--radius);
+ font-size: 0.875rem;
+ line-height: 1.5;
+ word-break: break-word;
+}
+
+.bubble.user {
+ white-space: pre-wrap;
+ background: var(--primary);
+ color: var(--primary-fg);
+ border-bottom-right-radius: 3px;
+}
+
+.bubble.assistant {
+ background: var(--muted);
+ color: var(--text);
+ border-bottom-left-radius: 3px;
+}
+
+.bubble.thinking {
+ color: var(--text-muted);
+ animation: pulse 1.5s ease-in-out infinite;
+}
+
+.bubble.assistant > * + * {
+ margin-top: 0.5em;
+}
+
+.bubble.assistant p {
+ margin: 0;
+}
+
+.bubble.assistant p + p {
+ margin-top: 0.4em;
+}
+
+.bubble.assistant code {
+ font-family: var(--mono);
+ font-size: 0.8em;
+ background: color-mix(in srgb, var(--text) 8%, transparent);
+ padding: 0.15em 0.35em;
+ border-radius: 4px;
+}
+
+.bubble.assistant pre {
+ margin: 0.5em 0;
+ padding: 0.75em;
+ border-radius: 6px;
+ background: color-mix(in srgb, var(--text) 6%, transparent);
+ overflow-x: auto;
+}
+
+.bubble.assistant pre code {
+ background: none;
+ padding: 0;
+ font-size: 0.8em;
+}
+
+.bubble.assistant ul,
+.bubble.assistant ol {
+ margin: 0.4em 0;
+ padding-left: 1.5em;
+}
+
+.bubble.assistant li {
+ margin: 0.15em 0;
+}
+
+.bubble.assistant h1,
+.bubble.assistant h2,
+.bubble.assistant h3 {
+ font-weight: 600;
+}
+
+.bubble.assistant h1 {
+ font-size: 1.1em;
+}
+.bubble.assistant h2 {
+ font-size: 1em;
+}
+.bubble.assistant h3 {
+ font-size: 0.95em;
+}
+
+.bubble.assistant blockquote {
+ margin: 0.4em 0;
+ padding-left: 0.75em;
+ border-left: 3px solid var(--border);
+ color: var(--text-muted);
+}
+
+.bubble.assistant table {
+ border-collapse: collapse;
+ margin: 0.5em 0;
+ font-size: 0.85em;
+}
+
+.bubble.assistant th,
+.bubble.assistant td {
+ border: 1px solid var(--border);
+ padding: 0.35em 0.6em;
+}
+
+.bubble.assistant th {
+ background: color-mix(in srgb, var(--text) 4%, transparent);
+ font-weight: 600;
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+.input-bar {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.875rem 1rem;
+ border-top: 1px solid var(--border);
+}
+
+.input-bar textarea {
+ flex: 1;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font);
+ font-size: 0.875rem;
+ resize: none;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.input-bar textarea:focus {
+ border-color: var(--ring);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 25%, transparent);
+}
+
+.input-bar textarea:disabled {
+ opacity: 0.5;
+}
+
+.input-bar button {
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: 8px;
+ background: var(--primary);
+ color: var(--primary-fg);
+ font-family: var(--font);
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: opacity 0.15s;
+ align-self: flex-end;
+}
+
+.input-bar button:hover:not(:disabled) {
+ opacity: 0.9;
+}
+
+.input-bar button:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.event-panel {
+ width: 300px;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--card);
+ overflow: hidden;
+}
+
+.event-header {
+ padding: 0.625rem 0.875rem;
+ border-bottom: 1px solid var(--border);
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.event-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.event-empty {
+ text-align: center;
+ padding: 2.5rem 0;
+ font-size: 0.75rem;
+ color: var(--text-faint);
+}
+
+.event-row {
+ font-family: var(--mono);
+ font-size: 0.7rem;
+ line-height: 1.4;
+ display: flex;
+ gap: 0.5rem;
+}
+
+.event-type {
+ flex-shrink: 0;
+ width: 90px;
+ text-align: right;
+ color: var(--text-faint);
+}
+
+.event-detail {
+ color: var(--text-muted);
+ word-break: break-all;
+}
diff --git a/apps/agent-app/src/App.tsx b/apps/agent-app/src/App.tsx
new file mode 100644
index 00000000..f8f03f4c
--- /dev/null
+++ b/apps/agent-app/src/App.tsx
@@ -0,0 +1,264 @@
+import { TooltipProvider } from "@databricks/appkit-ui/react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { marked } from "marked";
+import "./App.css";
+import { ThemeSelector } from "./components/theme-selector";
+
+interface AgentEvent {
+ type: string;
+ content?: string;
+ callId?: string;
+ name?: string;
+ args?: unknown;
+ result?: unknown;
+ error?: string;
+ status?: string;
+ data?: Record;
+}
+
+interface ChatMessage {
+ id: number;
+ role: "user" | "assistant";
+ content: string;
+}
+
+export default function App() {
+ const [messages, setMessages] = useState([]);
+ const [events, setEvents] = useState([]);
+ const [input, setInput] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const [threadId, setThreadId] = useState(null);
+ const [toolCount, setToolCount] = useState(0);
+ const messagesEndRef = useRef(null);
+ const idRef = useRef(0);
+
+ useEffect(() => {
+ fetch("/api/agent/tools")
+ .then((r) => r.json())
+ .then((data) => setToolCount(data.tools?.length ?? 0))
+ .catch(() => {});
+ }, []);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [messages]);
+
+ const sendMessage = useCallback(async () => {
+ if (!input.trim() || isLoading) return;
+
+ const text = input.trim();
+ setInput("");
+ setMessages((prev) => [
+ ...prev,
+ { id: ++idRef.current, role: "user", content: text },
+ ]);
+ setEvents([]);
+ setIsLoading(true);
+
+ try {
+ const res = await fetch("/api/agent/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: text,
+ ...(threadId && { threadId }),
+ }),
+ });
+
+ if (!res.ok) {
+ const err = await res.json();
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: ++idRef.current,
+ role: "assistant",
+ content: `Error: ${err.error}`,
+ },
+ ]);
+ return;
+ }
+
+ const reader = res.body?.getReader();
+ if (!reader) return;
+
+ const decoder = new TextDecoder();
+ let content = "";
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() ?? "";
+
+ for (const line of lines) {
+ if (!line.startsWith("data: ")) continue;
+ const data = line.slice(6).trim();
+ if (!data || data === "[DONE]") continue;
+ try {
+ const event: AgentEvent = JSON.parse(data);
+ setEvents((prev) => [...prev, event]);
+
+ if (event.type === "metadata" && event.data?.threadId) {
+ setThreadId(event.data.threadId as string);
+ }
+ if (event.type === "message_delta" && event.content) {
+ content += event.content;
+ setMessages((prev) => {
+ const updated = [...prev];
+ const last = updated[updated.length - 1];
+ if (last?.role === "assistant") {
+ updated[updated.length - 1] = { ...last, content };
+ } else {
+ updated.push({
+ id: ++idRef.current,
+ role: "assistant",
+ content,
+ });
+ }
+ return updated;
+ });
+ }
+ } catch {
+ /* skip */
+ }
+ }
+ }
+ } catch (err) {
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: ++idRef.current,
+ role: "assistant",
+ content: `Error: ${err instanceof Error ? err.message : "Unknown error"}`,
+ },
+ ]);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [input, isLoading, threadId]);
+
+ return (
+
+
+
+
+
+
+
+
+ {messages.length === 0 && (
+
+
+ Send a message to start a conversation
+
+
+ The agent can query data, browse files, and more
+
+
+ )}
+
+ {messages.map((msg) => (
+
+ ))}
+
+ {isLoading &&
+ messages[messages.length - 1]?.role === "user" && (
+
+ )}
+
+
+
+
+
+
+
+
+
Event Stream
+
+ {events.length === 0 && (
+
Events will appear here
+ )}
+ {events.map((event, i) => (
+
+ {event.type}
+
+ {event.type === "message_delta"
+ ? event.content?.slice(0, 60)
+ : event.type === "tool_call"
+ ? `${event.name}(${JSON.stringify(event.args).slice(0, 40)})`
+ : event.type === "tool_result"
+ ? `${String(event.result).slice(0, 60)}`
+ : event.type === "status"
+ ? event.status
+ : JSON.stringify(event).slice(0, 60)}
+
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/agent-app/src/components/theme-selector.tsx b/apps/agent-app/src/components/theme-selector.tsx
new file mode 100644
index 00000000..18bb4f14
--- /dev/null
+++ b/apps/agent-app/src/components/theme-selector.tsx
@@ -0,0 +1,135 @@
+import {
+ Button,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@databricks/appkit-ui/react";
+import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
+import { useEffect, useState } from "react";
+
+type Theme = "light" | "dark" | "system";
+
+const THEME_STORAGE_KEY = "agent-app-theme";
+
+function getSystemTheme(): "light" | "dark" {
+ if (typeof window === "undefined") return "light";
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+}
+
+function getStoredTheme(): Theme {
+ if (typeof window === "undefined") return "system";
+ const stored = localStorage.getItem(THEME_STORAGE_KEY);
+ return (stored as Theme) || "system";
+}
+
+function applyTheme(theme: Theme) {
+ if (typeof window === "undefined") return;
+
+ const root = document.documentElement;
+ root.classList.remove("light", "dark");
+
+ if (theme === "system") {
+ const systemTheme = getSystemTheme();
+ root.classList.add(systemTheme);
+ } else {
+ root.classList.add(theme);
+ }
+}
+
+export function ThemeSelector() {
+ const [theme, setTheme] = useState(() => getStoredTheme());
+ const [mounted, setMounted] = useState(false);
+ const [systemTheme, setSystemTheme] = useState<"light" | "dark">(() =>
+ getSystemTheme(),
+ );
+
+ useEffect(() => {
+ setMounted(true);
+ applyTheme(theme);
+ }, [theme]);
+
+ useEffect(() => {
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+ const handleChange = (e: MediaQueryListEvent | MediaQueryList) => {
+ const isDark = e.matches;
+ setSystemTheme(isDark ? "dark" : "light");
+ if (theme === "system") {
+ applyTheme("system");
+ }
+ };
+
+ handleChange(mediaQuery);
+
+ if (mediaQuery.addEventListener) {
+ mediaQuery.addEventListener("change", handleChange);
+ return () => mediaQuery.removeEventListener("change", handleChange);
+ } else {
+ mediaQuery.addListener(handleChange);
+ return () => mediaQuery.removeListener(handleChange);
+ }
+ }, [theme]);
+
+ const handleThemeChange = (newTheme: Theme) => {
+ setTheme(newTheme);
+ localStorage.setItem(THEME_STORAGE_KEY, newTheme);
+ applyTheme(newTheme);
+ };
+
+ const effectiveTheme = theme === "system" ? systemTheme : theme;
+
+ if (!mounted) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ handleThemeChange("light")}
+ className="cursor-pointer"
+ >
+
+ Light
+ {theme === "light" && ✓}
+
+ handleThemeChange("dark")}
+ className="cursor-pointer"
+ >
+
+ Dark
+ {theme === "dark" && ✓}
+
+ handleThemeChange("system")}
+ className="cursor-pointer"
+ >
+
+ System
+ {theme === "system" && ✓}
+
+
+
+ );
+}
diff --git a/apps/agent-app/src/index.css b/apps/agent-app/src/index.css
new file mode 100644
index 00000000..5dcc4cf8
--- /dev/null
+++ b/apps/agent-app/src/index.css
@@ -0,0 +1 @@
+@import "@databricks/appkit-ui/styles.css";
diff --git a/apps/agent-app/src/main.tsx b/apps/agent-app/src/main.tsx
new file mode 100644
index 00000000..98b62364
--- /dev/null
+++ b/apps/agent-app/src/main.tsx
@@ -0,0 +1,15 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App.tsx";
+import "./index.css";
+
+const rootElement = document.getElementById("root");
+if (!rootElement) {
+ throw new Error("Root element not found");
+}
+
+createRoot(rootElement).render(
+
+
+ ,
+);
diff --git a/apps/agent-app/tailwind.config.ts b/apps/agent-app/tailwind.config.ts
new file mode 100644
index 00000000..fad89bf6
--- /dev/null
+++ b/apps/agent-app/tailwind.config.ts
@@ -0,0 +1,11 @@
+import path from "node:path";
+import type { Config } from "tailwindcss";
+
+export default {
+ darkMode: ["class", "media"],
+ content: [
+ path.resolve(__dirname, "./index.html"),
+ path.resolve(__dirname, "./src/**/*.{js,ts,jsx,tsx}"),
+ ],
+ plugins: [require("tailwindcss-animate")],
+} satisfies Config;
diff --git a/apps/agent-app/tsconfig.app.json b/apps/agent-app/tsconfig.app.json
new file mode 100644
index 00000000..2877c218
--- /dev/null
+++ b/apps/agent-app/tsconfig.app.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/apps/agent-app/tsconfig.json b/apps/agent-app/tsconfig.json
new file mode 100644
index 00000000..1ffef600
--- /dev/null
+++ b/apps/agent-app/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/apps/agent-app/tsconfig.node.json b/apps/agent-app/tsconfig.node.json
new file mode 100644
index 00000000..35bcd118
--- /dev/null
+++ b/apps/agent-app/tsconfig.node.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "types": ["node"],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/apps/agent-app/vite.config.ts b/apps/agent-app/vite.config.ts
new file mode 100644
index 00000000..7cd00c30
--- /dev/null
+++ b/apps/agent-app/vite.config.ts
@@ -0,0 +1,26 @@
+import path from "node:path";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [react()],
+ optimizeDeps: {
+ include: [
+ "react",
+ "react-dom",
+ "react/jsx-dev-runtime",
+ "react/jsx-runtime",
+ ],
+ exclude: ["@databricks/appkit-ui", "@databricks/appkit"],
+ },
+ resolve: {
+ dedupe: ["react", "react-dom"],
+ preserveSymlinks: true,
+ alias: {
+ "@databricks/appkit-ui": path.resolve(
+ __dirname,
+ "../../packages/appkit-ui/dist",
+ ),
+ },
+ },
+});
diff --git a/docs/docs/api/appkit/Function.createAgent.md b/docs/docs/api/appkit/Function.createAgent.md
new file mode 100644
index 00000000..6981a315
--- /dev/null
+++ b/docs/docs/api/appkit/Function.createAgent.md
@@ -0,0 +1,52 @@
+# Function: createAgent()
+
+```ts
+function createAgent(config: CreateAgentConfig): Promise;
+```
+
+Creates an agent-powered app with batteries included.
+
+Wraps `createApp` with `server()` and `agent()` pre-configured.
+Automatically starts an HTTP server with agent chat routes.
+
+For apps that need custom routes or manual server control,
+use `createApp` with `server()` and `agent()` directly.
+
+## Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `config` | [`CreateAgentConfig`](Interface.CreateAgentConfig.md) |
+
+## Returns
+
+`Promise`\<[`AgentHandle`](Interface.AgentHandle.md)\>
+
+## Examples
+
+```ts
+import { createAgent, analytics } from "@databricks/appkit";
+import { DatabricksAdapter } from "@databricks/appkit/agents/databricks";
+
+createAgent({
+ plugins: [analytics()],
+ adapter: DatabricksAdapter.fromServingEndpoint({
+ workspaceClient: new WorkspaceClient({}),
+ endpointName: "databricks-claude-sonnet-4-5",
+ systemPrompt: "You are a data assistant...",
+ }),
+}).then(agent => {
+ console.log("Tools:", agent.getTools());
+});
+```
+
+```ts
+createAgent({
+ plugins: [analytics(), files()],
+ agents: {
+ assistant: DatabricksAdapter.fromServingEndpoint({ ... }),
+ autocomplete: DatabricksAdapter.fromServingEndpoint({ ... }),
+ },
+ defaultAgent: "assistant",
+});
+```
diff --git a/docs/docs/api/appkit/Interface.AgentHandle.md b/docs/docs/api/appkit/Interface.AgentHandle.md
new file mode 100644
index 00000000..1b5a09c2
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.AgentHandle.md
@@ -0,0 +1,66 @@
+# Interface: AgentHandle
+
+## Properties
+
+### getThreads()
+
+```ts
+getThreads: (userId: string) => Promise;
+```
+
+List threads for a user.
+
+#### Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `userId` | `string` |
+
+#### Returns
+
+`Promise`\<`unknown`\>
+
+***
+
+### getTools()
+
+```ts
+getTools: () => AgentToolDefinition[];
+```
+
+Get all tool definitions available to agents.
+
+#### Returns
+
+[`AgentToolDefinition`](Interface.AgentToolDefinition.md)[]
+
+***
+
+### plugins
+
+```ts
+plugins: Record;
+```
+
+Access to user-provided plugin APIs.
+
+***
+
+### registerAgent()
+
+```ts
+registerAgent: (name: string, adapter: AgentAdapter) => void;
+```
+
+Register an additional agent at runtime.
+
+#### Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `name` | `string` |
+| `adapter` | [`AgentAdapter`](Interface.AgentAdapter.md) |
+
+#### Returns
+
+`void`
diff --git a/docs/docs/api/appkit/Interface.CreateAgentConfig.md b/docs/docs/api/appkit/Interface.CreateAgentConfig.md
new file mode 100644
index 00000000..5d83b9e1
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.CreateAgentConfig.md
@@ -0,0 +1,95 @@
+# Interface: CreateAgentConfig
+
+## Properties
+
+### adapter?
+
+```ts
+optional adapter:
+ | AgentAdapter
+| Promise;
+```
+
+Single agent adapter (mutually exclusive with `agents`). Registered as "assistant".
+
+***
+
+### agents?
+
+```ts
+optional agents: Record>;
+```
+
+Multiple named agents (mutually exclusive with `adapter`).
+
+***
+
+### cache?
+
+```ts
+optional cache: CacheConfig;
+```
+
+Cache configuration.
+
+***
+
+### client?
+
+```ts
+optional client: WorkspaceClient;
+```
+
+Pre-configured WorkspaceClient.
+
+***
+
+### defaultAgent?
+
+```ts
+optional defaultAgent: string;
+```
+
+Which agent to use when the client doesn't specify one.
+
+***
+
+### host?
+
+```ts
+optional host: string;
+```
+
+Server host. Defaults to FLASK_RUN_HOST or 0.0.0.0.
+
+***
+
+### plugins?
+
+```ts
+optional plugins: PluginData[];
+```
+
+Tool-providing plugins (analytics, files, genie, lakebase, etc.)
+
+***
+
+### port?
+
+```ts
+optional port: number;
+```
+
+Server port. Defaults to DATABRICKS_APP_PORT or 8000.
+
+***
+
+### telemetry?
+
+```ts
+optional telemetry: TelemetryConfig;
+```
+
+Telemetry configuration.
diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md
index 5064713d..5056ba33 100644
--- a/docs/docs/api/appkit/index.md
+++ b/docs/docs/api/appkit/index.md
@@ -31,11 +31,13 @@ plugin architecture, and React integration.
| Interface | Description |
| ------ | ------ |
| [AgentAdapter](Interface.AgentAdapter.md) | - |
+| [AgentHandle](Interface.AgentHandle.md) | - |
| [AgentInput](Interface.AgentInput.md) | - |
| [AgentRunContext](Interface.AgentRunContext.md) | - |
| [AgentToolDefinition](Interface.AgentToolDefinition.md) | - |
| [BasePluginConfig](Interface.BasePluginConfig.md) | Base configuration interface for AppKit plugins |
| [CacheConfig](Interface.CacheConfig.md) | Configuration for the CacheInterceptor. Controls TTL, size limits, storage backend, and probabilistic cleanup. |
+| [CreateAgentConfig](Interface.CreateAgentConfig.md) | - |
| [DatabaseCredential](Interface.DatabaseCredential.md) | Database credentials with OAuth token for Postgres connection |
| [GenerateDatabaseCredentialRequest](Interface.GenerateDatabaseCredentialRequest.md) | Request parameters for generating database OAuth credentials |
| [ITelemetry](Interface.ITelemetry.md) | Plugin-facing interface for OpenTelemetry instrumentation. Provides a thin abstraction over OpenTelemetry APIs for plugins. |
@@ -76,6 +78,7 @@ plugin architecture, and React integration.
| Function | Description |
| ------ | ------ |
| [appKitTypesPlugin](Function.appKitTypesPlugin.md) | Vite plugin to generate types for AppKit queries. Calls generateFromEntryPoint under the hood. |
+| [createAgent](Function.createAgent.md) | Creates an agent-powered app with batteries included. |
| [createApp](Function.createApp.md) | Bootstraps AppKit with the provided configuration. |
| [createLakebasePool](Function.createLakebasePool.md) | Create a Lakebase pool with appkit's logger integration. Telemetry automatically uses appkit's OpenTelemetry configuration via global registry. |
| [generateDatabaseCredential](Function.generateDatabaseCredential.md) | Generate OAuth credentials for Postgres database connection using the proper Postgres API. |
diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts
index cf28729b..b7e6a80c 100644
--- a/docs/docs/api/appkit/typedoc-sidebar.ts
+++ b/docs/docs/api/appkit/typedoc-sidebar.ts
@@ -87,6 +87,11 @@ const typedocSidebar: SidebarsConfig = {
id: "api/appkit/Interface.AgentAdapter",
label: "AgentAdapter"
},
+ {
+ type: "doc",
+ id: "api/appkit/Interface.AgentHandle",
+ label: "AgentHandle"
+ },
{
type: "doc",
id: "api/appkit/Interface.AgentInput",
@@ -112,6 +117,11 @@ const typedocSidebar: SidebarsConfig = {
id: "api/appkit/Interface.CacheConfig",
label: "CacheConfig"
},
+ {
+ type: "doc",
+ id: "api/appkit/Interface.CreateAgentConfig",
+ label: "CreateAgentConfig"
+ },
{
type: "doc",
id: "api/appkit/Interface.DatabaseCredential",
@@ -255,6 +265,11 @@ const typedocSidebar: SidebarsConfig = {
id: "api/appkit/Function.appKitTypesPlugin",
label: "appKitTypesPlugin"
},
+ {
+ type: "doc",
+ id: "api/appkit/Function.createAgent",
+ label: "createAgent"
+ },
{
type: "doc",
id: "api/appkit/Function.createApp",
diff --git a/package.json b/package.json
index 351b3f94..9d99c4ec 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"clean:full": "rm -rf node_modules dist coverage && pnpm -r clean:full",
"clean": "pnpm -r clean",
"dev": "pnpm build && NODE_ENV=development turbo watch build:watch dev",
+ "dev:agent": "pnpm build && NODE_ENV=development pnpm --filter=agent-app dev",
"dev:inspect": "NODE_ENV=development pnpm --filter=dev-playground dev:inspect",
"docs:dev": "pnpm --filter=docs dev",
"docs:build": "pnpm --filter=docs build",
diff --git a/packages/appkit/src/agents/databricks.ts b/packages/appkit/src/agents/databricks.ts
index cf8229a7..79ab0ed1 100644
--- a/packages/appkit/src/agents/databricks.ts
+++ b/packages/appkit/src/agents/databricks.ts
@@ -450,7 +450,6 @@ export async function createDatabricksModel(
): Promise {
let createOpenAI: any;
try {
- // @ts-expect-error -- optional peer dependency, may not be installed
const mod = await import("@ai-sdk/openai");
createOpenAI = mod.createOpenAI;
} catch {
diff --git a/packages/appkit/src/agents/langchain.ts b/packages/appkit/src/agents/langchain.ts
index 9fc184ed..a1eb5f75 100644
--- a/packages/appkit/src/agents/langchain.ts
+++ b/packages/appkit/src/agents/langchain.ts
@@ -35,7 +35,6 @@ export class LangChainAdapter implements AgentAdapter {
input: AgentInput,
context: AgentRunContext,
): AsyncGenerator {
- // @ts-expect-error -- optional peer dependency, may not be installed
const lcTools = await import("@langchain/core/tools");
const DynamicStructuredTool = lcTools.DynamicStructuredTool;
const zodModule: any = await import("zod");
diff --git a/packages/appkit/src/core/create-agent.ts b/packages/appkit/src/core/create-agent.ts
new file mode 100644
index 00000000..28e5a576
--- /dev/null
+++ b/packages/appkit/src/core/create-agent.ts
@@ -0,0 +1,130 @@
+import type { WorkspaceClient } from "@databricks/sdk-experimental";
+import type {
+ AgentAdapter,
+ AgentToolDefinition,
+ CacheConfig,
+ PluginConstructor,
+ PluginData,
+} from "shared";
+import { agent } from "../plugins/agent";
+import { server } from "../plugins/server";
+import type { TelemetryConfig } from "../telemetry";
+import { createApp } from "./appkit";
+
+export interface CreateAgentConfig {
+ /** Single agent adapter (mutually exclusive with `agents`). Registered as "assistant". */
+ adapter?: AgentAdapter | Promise;
+ /** Multiple named agents (mutually exclusive with `adapter`). */
+ agents?: Record>;
+ /** Which agent to use when the client doesn't specify one. */
+ defaultAgent?: string;
+ /** Tool-providing plugins (analytics, files, genie, lakebase, etc.) */
+ plugins?: PluginData[];
+ /** Server port. Defaults to DATABRICKS_APP_PORT or 8000. */
+ port?: number;
+ /** Server host. Defaults to FLASK_RUN_HOST or 0.0.0.0. */
+ host?: string;
+ /** Telemetry configuration. */
+ telemetry?: TelemetryConfig;
+ /** Cache configuration. */
+ cache?: CacheConfig;
+ /** Pre-configured WorkspaceClient. */
+ client?: WorkspaceClient;
+}
+
+export interface AgentHandle {
+ /** Register an additional agent at runtime. */
+ registerAgent: (name: string, adapter: AgentAdapter) => void;
+ /** Get all tool definitions available to agents. */
+ getTools: () => AgentToolDefinition[];
+ /** List threads for a user. */
+ getThreads: (userId: string) => Promise;
+ /** Access to user-provided plugin APIs. */
+ plugins: Record;
+}
+
+/**
+ * Creates an agent-powered app with batteries included.
+ *
+ * Wraps `createApp` with `server()` and `agent()` pre-configured.
+ * Automatically starts an HTTP server with agent chat routes.
+ *
+ * For apps that need custom routes or manual server control,
+ * use `createApp` with `server()` and `agent()` directly.
+ *
+ * @example Single agent
+ * ```ts
+ * import { createAgent, analytics } from "@databricks/appkit";
+ * import { DatabricksAdapter } from "@databricks/appkit/agents/databricks";
+ *
+ * createAgent({
+ * plugins: [analytics()],
+ * adapter: DatabricksAdapter.fromServingEndpoint({
+ * workspaceClient: new WorkspaceClient({}),
+ * endpointName: "databricks-claude-sonnet-4-5",
+ * systemPrompt: "You are a data assistant...",
+ * }),
+ * }).then(agent => {
+ * console.log("Tools:", agent.getTools());
+ * });
+ * ```
+ *
+ * @example Multiple agents
+ * ```ts
+ * createAgent({
+ * plugins: [analytics(), files()],
+ * agents: {
+ * assistant: DatabricksAdapter.fromServingEndpoint({ ... }),
+ * autocomplete: DatabricksAdapter.fromServingEndpoint({ ... }),
+ * },
+ * defaultAgent: "assistant",
+ * });
+ * ```
+ */
+export async function createAgent(
+ config: CreateAgentConfig,
+): Promise {
+ if (config.adapter && config.agents) {
+ throw new Error(
+ "createAgent: 'adapter' and 'agents' are mutually exclusive. " +
+ "Use 'adapter' for a single agent or 'agents' for multiple.",
+ );
+ }
+
+ const agents = config.adapter ? { assistant: config.adapter } : config.agents;
+
+ const appkit = await createApp({
+ plugins: [
+ agent({
+ agents,
+ defaultAgent: config.defaultAgent,
+ }),
+ ...(config.plugins ?? []),
+ server({
+ autoStart: true,
+ ...(config.port !== undefined && { port: config.port }),
+ ...(config.host !== undefined && { host: config.host }),
+ }),
+ ],
+ telemetry: config.telemetry,
+ cache: config.cache,
+ client: config.client,
+ });
+
+ const agentExports = (appkit as any).agent;
+ const hiddenKeys = new Set(["agent", "server"]);
+
+ const plugins: Record = {};
+ for (const [key, value] of Object.entries(appkit as Record)) {
+ if (!hiddenKeys.has(key)) {
+ plugins[key] = value;
+ }
+ }
+
+ return {
+ registerAgent: agentExports.registerAgent,
+ getTools: agentExports.getTools,
+ getThreads: agentExports.getThreads,
+ plugins,
+ };
+}
diff --git a/packages/appkit/src/core/tests/create-agent.test.ts b/packages/appkit/src/core/tests/create-agent.test.ts
new file mode 100644
index 00000000..543cb82e
--- /dev/null
+++ b/packages/appkit/src/core/tests/create-agent.test.ts
@@ -0,0 +1,206 @@
+import { describe, expect, test, vi } from "vitest";
+
+vi.mock("../../cache", () => ({
+ CacheManager: {
+ getInstance: vi.fn().mockResolvedValue({
+ get: vi.fn(),
+ set: vi.fn(),
+ delete: vi.fn(),
+ getOrExecute: vi.fn(),
+ }),
+ getInstanceSync: vi.fn().mockReturnValue({
+ get: vi.fn(),
+ set: vi.fn(),
+ delete: vi.fn(),
+ getOrExecute: vi.fn(),
+ }),
+ },
+}));
+
+vi.mock("../../telemetry", () => ({
+ TelemetryManager: {
+ initialize: vi.fn(),
+ getProvider: vi.fn(() => ({
+ getTracer: vi.fn(),
+ getMeter: vi.fn(),
+ getLogger: vi.fn(),
+ emit: vi.fn(),
+ startActiveSpan: vi.fn(),
+ registerInstrumentations: vi.fn(),
+ })),
+ },
+ normalizeTelemetryOptions: vi.fn(() => ({
+ traces: false,
+ metrics: false,
+ logs: false,
+ })),
+}));
+
+vi.mock("../../context/service-context", () => {
+ const mockClient = {
+ statementExecution: { executeStatement: vi.fn() },
+ currentUser: { me: vi.fn().mockResolvedValue({ id: "test-user" }) },
+ config: { host: "https://test.databricks.com" },
+ };
+
+ return {
+ ServiceContext: {
+ initialize: vi.fn().mockResolvedValue({
+ client: mockClient,
+ serviceUserId: "test-service-user",
+ workspaceId: Promise.resolve("test-workspace"),
+ }),
+ get: vi.fn().mockReturnValue({
+ client: mockClient,
+ serviceUserId: "test-service-user",
+ workspaceId: Promise.resolve("test-workspace"),
+ }),
+ isInitialized: vi.fn().mockReturnValue(true),
+ createUserContext: vi.fn(),
+ },
+ };
+});
+
+vi.mock("../../registry", () => ({
+ ResourceRegistry: vi.fn().mockImplementation(() => ({
+ collectResources: vi.fn(),
+ getRequired: vi.fn().mockReturnValue([]),
+ enforceValidation: vi.fn(),
+ })),
+ ResourceType: { SQL_WAREHOUSE: "sql_warehouse" },
+ getPluginManifest: vi.fn(),
+ getResourceRequirements: vi.fn(),
+}));
+
+// Mock server plugin to avoid actually starting a server
+vi.mock("../../plugins/server", () => {
+ const manifest = {
+ name: "server",
+ displayName: "Server",
+ description: "Server",
+ resources: { required: [], optional: [] },
+ };
+
+ class MockServerPlugin {
+ static manifest = manifest;
+ static phase = "deferred";
+ static DEFAULT_CONFIG = {};
+ name = "server";
+ config: any;
+ constructor(config: any) {
+ this.config = config;
+ }
+ async setup() {}
+ injectRoutes() {}
+ getEndpoints() {
+ return {};
+ }
+ exports() {
+ return {
+ start: vi.fn(),
+ extend: vi.fn(),
+ getServer: vi.fn(),
+ getConfig: vi.fn(() => this.config),
+ };
+ }
+ }
+
+ return {
+ server: (config: any = {}) => ({
+ plugin: MockServerPlugin,
+ config,
+ name: "server",
+ }),
+ ServerPlugin: MockServerPlugin,
+ };
+});
+
+import type { AgentAdapter, AgentEvent } from "shared";
+import { createAgent } from "../create-agent";
+
+function createMockAdapter(): AgentAdapter {
+ return {
+ async *run(): AsyncGenerator {
+ yield { type: "message_delta", content: "hello" };
+ },
+ };
+}
+
+describe("createAgent", () => {
+ test("returns an AgentHandle with registerAgent, getTools, getThreads", async () => {
+ const handle = await createAgent({
+ adapter: createMockAdapter(),
+ });
+
+ expect(handle.registerAgent).toBeTypeOf("function");
+ expect(handle.getTools).toBeTypeOf("function");
+ expect(handle.getThreads).toBeTypeOf("function");
+ expect(handle.plugins).toBeDefined();
+ });
+
+ test("adapter shorthand registers as 'assistant'", async () => {
+ const handle = await createAgent({
+ adapter: createMockAdapter(),
+ });
+
+ const tools = handle.getTools();
+ expect(tools).toBeInstanceOf(Array);
+ });
+
+ test("agents record is passed through", async () => {
+ const handle = await createAgent({
+ agents: {
+ main: createMockAdapter(),
+ secondary: createMockAdapter(),
+ },
+ defaultAgent: "main",
+ });
+
+ expect(handle.getTools).toBeTypeOf("function");
+ });
+
+ test("throws when both adapter and agents are provided", async () => {
+ await expect(
+ createAgent({
+ adapter: createMockAdapter(),
+ agents: { other: createMockAdapter() },
+ }),
+ ).rejects.toThrow("mutually exclusive");
+ });
+
+ test("plugins namespace excludes agent and server", async () => {
+ const handle = await createAgent({
+ adapter: createMockAdapter(),
+ });
+
+ expect(handle.plugins).not.toHaveProperty("agent");
+ expect(handle.plugins).not.toHaveProperty("server");
+ });
+
+ test("accepts port and host config", async () => {
+ const handle = await createAgent({
+ adapter: createMockAdapter(),
+ port: 9000,
+ host: "127.0.0.1",
+ });
+
+ expect(handle).toBeDefined();
+ });
+
+ test("works with promised adapters", async () => {
+ const handle = await createAgent({
+ adapter: Promise.resolve(createMockAdapter()),
+ });
+
+ expect(handle.registerAgent).toBeTypeOf("function");
+ });
+
+ test("registerAgent allows adding agents after creation", async () => {
+ const handle = await createAgent({
+ adapter: createMockAdapter(),
+ });
+
+ handle.registerAgent("second", createMockAdapter());
+ expect(handle.getTools).toBeTypeOf("function");
+ });
+});
diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts
index f697c50b..2b7dd455 100644
--- a/packages/appkit/src/index.ts
+++ b/packages/appkit/src/index.ts
@@ -43,6 +43,11 @@ export {
} from "./connectors/lakebase";
export { getExecutionContext } from "./context";
export { createApp } from "./core";
+export {
+ createAgent,
+ type AgentHandle,
+ type CreateAgentConfig,
+} from "./core/create-agent";
// Errors
export {
AppKitError,
diff --git a/packages/appkit/src/plugins/agent/index.ts b/packages/appkit/src/plugins/agent/index.ts
index 66b07e47..861a68cc 100644
--- a/packages/appkit/src/plugins/agent/index.ts
+++ b/packages/appkit/src/plugins/agent/index.ts
@@ -1,3 +1 @@
-export { agent } from "./agent";
-;
-;
+export { agent } from "./agent";
diff --git a/packages/appkit/src/plugins/agent/types.ts b/packages/appkit/src/plugins/agent/types.ts
index 2934f558..51a18189 100644
--- a/packages/appkit/src/plugins/agent/types.ts
+++ b/packages/appkit/src/plugins/agent/types.ts
@@ -26,9 +26,6 @@ export type RegisteredAgent = {
export type {
AgentAdapter,
-
-
-
AgentToolDefinition,
ToolProvider,
} from "shared";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 63ecd038..dff7ca45 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -78,6 +78,70 @@ importers:
specifier: 3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2)
+ apps/agent-app:
+ dependencies:
+ '@databricks/appkit':
+ specifier: workspace:*
+ version: link:../../packages/appkit
+ '@databricks/appkit-ui':
+ specifier: workspace:*
+ version: link:../../packages/appkit-ui
+ '@databricks/sdk-experimental':
+ specifier: ^0.16.0
+ version: 0.16.0
+ dotenv:
+ specifier: ^16.6.1
+ version: 16.6.1
+ lucide-react:
+ specifier: ^0.511.0
+ version: 0.511.0(react@19.2.0)
+ marked:
+ specifier: ^15.0.0
+ version: 15.0.12
+ react:
+ specifier: 19.2.0
+ version: 19.2.0
+ react-dom:
+ specifier: 19.2.0
+ version: 19.2.0(react@19.2.0)
+ devDependencies:
+ '@tailwindcss/postcss':
+ specifier: 4.1.17
+ version: 4.1.17
+ '@types/node':
+ specifier: 24.10.1
+ version: 24.10.1
+ '@types/react':
+ specifier: 19.2.7
+ version: 19.2.7
+ '@types/react-dom':
+ specifier: 19.2.3
+ version: 19.2.3(@types/react@19.2.7)
+ '@vitejs/plugin-react':
+ specifier: 5.1.1
+ version: 5.1.1(rolldown-vite@7.1.14(@types/node@24.10.1)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2))
+ autoprefixer:
+ specifier: 10.4.21
+ version: 10.4.21(postcss@8.5.6)
+ postcss:
+ specifier: 8.5.6
+ version: 8.5.6
+ tailwindcss:
+ specifier: 4.1.17
+ version: 4.1.17
+ tailwindcss-animate:
+ specifier: 1.0.7
+ version: 1.0.7(tailwindcss@4.1.17)
+ tsx:
+ specifier: 4.20.6
+ version: 4.20.6
+ typescript:
+ specifier: 5.9.3
+ version: 5.9.3
+ vite:
+ specifier: npm:rolldown-vite@7.1.14
+ version: rolldown-vite@7.1.14(@types/node@24.10.1)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2)
+
apps/clean-app:
dependencies:
'@databricks/appkit':
@@ -145,12 +209,18 @@ importers:
specifier: 0.3.28
version: 0.3.28(pg@8.18.0)
devDependencies:
+ '@ai-sdk/openai':
+ specifier: 1.0.0
+ version: 1.0.0(zod@4.1.13)
'@playwright/test':
specifier: 1.58.1
version: 1.58.1
'@types/node':
specifier: 20.19.21
version: 20.19.21
+ ai:
+ specifier: 4.0.0
+ version: 4.0.0(react@19.2.0)(zod@4.1.13)
dotenv:
specifier: 16.6.1
version: 16.6.1
@@ -245,6 +315,9 @@ importers:
'@databricks/sdk-experimental':
specifier: 0.16.0
version: 0.16.0
+ '@langchain/core':
+ specifier: '>=0.3.0'
+ version: 1.1.34(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(ws@8.18.3(bufferutil@4.0.9))
'@opentelemetry/api':
specifier: 1.9.0
version: 1.9.0
@@ -293,6 +366,9 @@ importers:
'@types/semver':
specifier: 7.7.1
version: 7.7.1
+ ai:
+ specifier: '>=4.0.0'
+ version: 4.0.0(react@19.2.0)(zod@4.1.13)
dotenv:
specifier: 16.6.1
version: 16.6.1
@@ -320,6 +396,9 @@ importers:
ws:
specifier: 8.18.3
version: 8.18.3(bufferutil@4.0.9)
+ zod:
+ specifier: '>=3.0.0'
+ version: 4.1.13
devDependencies:
'@types/express':
specifier: 4.17.25
@@ -552,16 +631,47 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
+ '@ai-sdk/openai@1.0.0':
+ resolution: {integrity: sha512-EZ2UDxTBb3v3e2eexKTFGXF9MEy7rEcfIrkdD3yo8RCpwIkwRjyxCfs6wzh8KAW6XQZRu3Rp0kqw1S4FQcQgJA==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ zod: ^3.0.0
+
+ '@ai-sdk/provider-utils@2.0.0':
+ resolution: {integrity: sha512-uITgVJByhtzuQU2ZW+2CidWRmQqTUTp6KADevy+4aRnmILZxY2LCt+UZ/ZtjJqq0MffwkuQPPY21ExmFAQ6kKA==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ zod: ^3.0.0
+ peerDependenciesMeta:
+ zod:
+ optional: true
+
'@ai-sdk/provider-utils@3.0.19':
resolution: {integrity: sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
+ '@ai-sdk/provider@1.0.0':
+ resolution: {integrity: sha512-Sj29AzooJ7SYvhPd+AAWt/E7j63E9+AzRnoMHUaJPRYzOd/WDrVNxxv85prF9gDcQ7XPVlSk9j6oAZV9/DXYpA==}
+ engines: {node: '>=18'}
+
'@ai-sdk/provider@2.0.0':
resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==}
engines: {node: '>=18'}
+ '@ai-sdk/react@1.0.0':
+ resolution: {integrity: sha512-BDrZqQA07Btg64JCuhFvBgYV+tt2B8cXINzEqWknGoxqcwgdE8wSLG2gkXoLzyC2Rnj7oj0HHpOhLUxDCmoKZg==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ react: ^18 || ^19 || ^19.0.0-rc
+ zod: ^3.0.0
+ peerDependenciesMeta:
+ react:
+ optional: true
+ zod:
+ optional: true
+
'@ai-sdk/react@2.0.115':
resolution: {integrity: sha512-Etu7gWSEi2dmXss1PoR5CAZGwGShXsF9+Pon1eRO6EmatjYaBMhq1CfHPyYhGzWrint8jJIK2VaAhiMef29qZw==}
engines: {node: '>=18'}
@@ -572,6 +682,15 @@ packages:
zod:
optional: true
+ '@ai-sdk/ui-utils@1.0.0':
+ resolution: {integrity: sha512-oXBDIM/0niWeTWyw77RVl505dNxBUDLLple7bTsqo2d3i1UKwGlzBUX8XqZsh7GbY7I6V05nlG0Y8iGlWxv1Aw==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ zod: ^3.0.0
+ peerDependenciesMeta:
+ zod:
+ optional: true
+
'@algolia/abtesting@1.12.0':
resolution: {integrity: sha512-EfW0bfxjPs+C7ANkJDw2TATntfBKsFiy7APh+KO0pQ8A6HYa5I0NjFuCGCXWfzzzLXNZta3QUl3n5Kmm6aJo9Q==}
engines: {node: '>= 14.0.0'}
@@ -1403,6 +1522,9 @@ packages:
'@braintree/sanitize-url@7.1.1':
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
+ '@cfworker/json-schema@4.1.1':
+ resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
+
'@chevrotain/cst-dts-gen@11.0.3':
resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==}
@@ -2029,15 +2151,9 @@ packages:
resolution: {integrity: sha512-lBSBiRruFurFKXr5Hbsl2thmGweAPmddhF3jb99U4EMDA5L+e5Y1rAkOS07Nvrup7HUMBDrCV45meaxZnt28nQ==}
engines: {node: '>=20.0'}
- '@emnapi/core@1.7.1':
- resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
-
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
- '@emnapi/runtime@1.7.1':
- resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
-
'@emnapi/runtime@1.8.1':
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
@@ -2504,6 +2620,10 @@ packages:
peerDependencies:
tslib: '2'
+ '@langchain/core@1.1.34':
+ resolution: {integrity: sha512-IDlZES5Vexo5meLQRCGkAU7NM0tPGPfPP5wcUzBd7Ot+JoFBmSXutC4gGzvZod5AKRVn3I0Qy5k8vkTraY21jA==}
+ engines: {node: '>=20'}
+
'@leichtgewicht/ip-codec@2.0.5':
resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==}
@@ -2519,9 +2639,6 @@ packages:
'@mermaid-js/parser@0.6.3':
resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==}
- '@napi-rs/wasm-runtime@1.0.7':
- resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==}
-
'@napi-rs/wasm-runtime@1.1.1':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
@@ -4434,39 +4551,79 @@ packages:
resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==}
engines: {node: '>=14.16'}
+ '@tailwindcss/node@4.1.17':
+ resolution: {integrity: sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==}
+
'@tailwindcss/node@4.1.18':
resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
+ '@tailwindcss/oxide-android-arm64@4.1.17':
+ resolution: {integrity: sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
'@tailwindcss/oxide-android-arm64@4.1.18':
resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
+ '@tailwindcss/oxide-darwin-arm64@4.1.17':
+ resolution: {integrity: sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
'@tailwindcss/oxide-darwin-arm64@4.1.18':
resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
+ '@tailwindcss/oxide-darwin-x64@4.1.17':
+ resolution: {integrity: sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
'@tailwindcss/oxide-darwin-x64@4.1.18':
resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
+ '@tailwindcss/oxide-freebsd-x64@4.1.17':
+ resolution: {integrity: sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [freebsd]
+
'@tailwindcss/oxide-freebsd-x64@4.1.18':
resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17':
+ resolution: {integrity: sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.17':
+ resolution: {integrity: sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
'@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==}
engines: {node: '>= 10'}
@@ -4474,6 +4631,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.17':
+ resolution: {integrity: sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
@@ -4481,6 +4645,13 @@ packages:
os: [linux]
libc: [musl]
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.17':
+ resolution: {integrity: sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
@@ -4488,6 +4659,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@tailwindcss/oxide-linux-x64-musl@4.1.17':
+ resolution: {integrity: sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
@@ -4495,6 +4673,18 @@ packages:
os: [linux]
libc: [musl]
+ '@tailwindcss/oxide-wasm32-wasi@4.1.17':
+ resolution: {integrity: sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
engines: {node: '>=14.0.0'}
@@ -4507,22 +4697,41 @@ packages:
- '@emnapi/wasi-threads'
- tslib
+ '@tailwindcss/oxide-win32-arm64-msvc@4.1.17':
+ resolution: {integrity: sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
'@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.17':
+ resolution: {integrity: sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
'@tailwindcss/oxide-win32-x64-msvc@4.1.18':
resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
+ '@tailwindcss/oxide@4.1.17':
+ resolution: {integrity: sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==}
+ engines: {node: '>= 10'}
+
'@tailwindcss/oxide@4.1.18':
resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==}
engines: {node: '>= 10'}
+ '@tailwindcss/postcss@4.1.17':
+ resolution: {integrity: sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==}
+
'@tailwindcss/postcss@4.1.18':
resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
@@ -4710,6 +4919,9 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+ '@types/diff-match-patch@1.0.36':
+ resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==}
+
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -4907,6 +5119,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+ '@types/uuid@10.0.0':
+ resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
+
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
@@ -5130,6 +5345,18 @@ packages:
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
engines: {node: '>=8'}
+ ai@4.0.0:
+ resolution: {integrity: sha512-cqf2GCaXnOPhUU+Ccq6i+5I0jDjnFkzfq7t6mc0SUSibSa1wDPn5J4p8+Joh2fDGDYZOJ44rpTW9hSs40rXNAw==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ react: ^18 || ^19 || ^19.0.0-rc
+ zod: ^3.0.0
+ peerDependenciesMeta:
+ react:
+ optional: true
+ zod:
+ optional: true
+
ai@5.0.113:
resolution: {integrity: sha512-26vivpSO/mzZj0k1Si2IpsFspp26ttQICHRySQiMrtWcRd5mnJMX2a8sG28vmZ38C+JUn1cWmfZrsLMxkSMw9g==}
engines: {node: '>=18'}
@@ -5284,6 +5511,13 @@ packages:
autocomplete.js@0.37.1:
resolution: {integrity: sha512-PgSe9fHYhZEsm/9jggbjtVsGXJkPLvd+9mC7gZJ662vVL5CRWEtm/mIrrzCx0MrNxHVwxD5d00UOn6NsmL2LUQ==}
+ autoprefixer@10.4.21:
+ resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==}
+ engines: {node: ^10 || ^12 || >=14}
+ hasBin: true
+ peerDependencies:
+ postcss: ^8.1.0
+
autoprefixer@10.4.23:
resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==}
engines: {node: ^10 || ^12 || >=14}
@@ -5743,6 +5977,9 @@ packages:
console-control-strings@1.1.0:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
+ console-table-printer@2.15.0:
+ resolution: {integrity: sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==}
+
content-disposition@0.5.2:
resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==}
engines: {node: '>= 0.6'}
@@ -6201,6 +6438,10 @@ packages:
supports-color:
optional: true
+ decamelize@1.2.0:
+ resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+ engines: {node: '>=0.10.0'}
+
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
@@ -6312,6 +6553,9 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+ diff-match-patch@1.0.5:
+ resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
+
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@@ -6955,6 +7199,9 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
+ fraction.js@4.3.7:
+ resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
+
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
@@ -7777,6 +8024,9 @@ packages:
joi@17.13.3:
resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==}
+ js-tiktoken@1.0.21:
+ resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -7840,6 +8090,11 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ jsondiffpatch@0.6.0:
+ resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
@@ -7883,6 +8138,26 @@ packages:
resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==}
engines: {node: '>=16.0.0'}
+ langsmith@0.5.11:
+ resolution: {integrity: sha512-Yio502Ow2vbVt16P1sybNMNpMsr5BMqoeonoi4flrcDsP55No/aCe2zydtBNOv0+kjKQw4WSKAzTsNwenDeD5w==}
+ peerDependencies:
+ '@opentelemetry/api': '*'
+ '@opentelemetry/exporter-trace-otlp-proto': '*'
+ '@opentelemetry/sdk-trace-base': '*'
+ openai: '*'
+ ws: '>=7'
+ peerDependenciesMeta:
+ '@opentelemetry/api':
+ optional: true
+ '@opentelemetry/exporter-trace-otlp-proto':
+ optional: true
+ '@opentelemetry/sdk-trace-base':
+ optional: true
+ openai:
+ optional: true
+ ws:
+ optional: true
+
latest-version@7.0.0:
resolution: {integrity: sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==}
engines: {node: '>=14.16'}
@@ -8109,6 +8384,11 @@ packages:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'}
+ lucide-react@0.511.0:
+ resolution: {integrity: sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
lucide-react@0.554.0:
resolution: {integrity: sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==}
peerDependencies:
@@ -8155,6 +8435,11 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
+ marked@15.0.12:
+ resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==}
+ engines: {node: '>= 18'}
+ hasBin: true
+
marked@16.4.2:
resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==}
engines: {node: '>= 20'}
@@ -8500,6 +8785,10 @@ packages:
resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==}
hasBin: true
+ mustache@4.2.0:
+ resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
+ hasBin: true
+
mute-stream@2.0.0:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -8509,6 +8798,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ nanoid@5.1.7:
+ resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==}
+ engines: {node: ^18 || >=20}
+ hasBin: true
+
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -8588,6 +8882,10 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
+ normalize-range@0.1.2:
+ resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==}
+ engines: {node: '>=0.10.0'}
+
normalize-url@8.1.0:
resolution: {integrity: sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w==}
engines: {node: '>=14.16'}
@@ -9793,6 +10091,7 @@ packages:
rolldown-vite@7.1.14:
resolution: {integrity: sha512-eSiiRJmovt8qDJkGyZuLnbxAOAdie6NCmmd0NkTC0RJI9duiSBTfr8X2mBYJOUFzxQa2USaHmL99J9uMxkjCyw==}
engines: {node: ^20.19.0 || >=22.12.0}
+ deprecated: Use 7.3.1 for migration purposes. For the most recent updates, migrate to Vite 8 once you're ready.
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
@@ -9919,6 +10218,9 @@ packages:
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
engines: {node: '>=4'}
+ secure-json-parse@2.7.0:
+ resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
+
select-hose@2.0.0:
resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==}
@@ -10058,6 +10360,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
+ simple-wcswidth@1.1.2:
+ resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==}
+
sirv@2.0.4:
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
engines: {node: '>= 10'}
@@ -10885,6 +11190,10 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
+ uuid@10.0.0:
+ resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
+ hasBin: true
+
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
@@ -11297,6 +11606,11 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
+ zod-to-json-schema@3.25.1:
+ resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
+ peerDependencies:
+ zod: ^3.25 || ^4
+
zod-validation-error@4.0.2:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'}
@@ -11324,6 +11638,21 @@ snapshots:
'@vercel/oidc': 3.0.5
zod: 4.1.13
+ '@ai-sdk/openai@1.0.0(zod@4.1.13)':
+ dependencies:
+ '@ai-sdk/provider': 1.0.0
+ '@ai-sdk/provider-utils': 2.0.0(zod@4.1.13)
+ zod: 4.1.13
+
+ '@ai-sdk/provider-utils@2.0.0(zod@4.1.13)':
+ dependencies:
+ '@ai-sdk/provider': 1.0.0
+ eventsource-parser: 3.0.6
+ nanoid: 5.1.7
+ secure-json-parse: 2.7.0
+ optionalDependencies:
+ zod: 4.1.13
+
'@ai-sdk/provider-utils@3.0.19(zod@4.1.13)':
dependencies:
'@ai-sdk/provider': 2.0.0
@@ -11331,10 +11660,24 @@ snapshots:
eventsource-parser: 3.0.6
zod: 4.1.13
+ '@ai-sdk/provider@1.0.0':
+ dependencies:
+ json-schema: 0.4.0
+
'@ai-sdk/provider@2.0.0':
dependencies:
json-schema: 0.4.0
+ '@ai-sdk/react@1.0.0(react@19.2.0)(zod@4.1.13)':
+ dependencies:
+ '@ai-sdk/provider-utils': 2.0.0(zod@4.1.13)
+ '@ai-sdk/ui-utils': 1.0.0(zod@4.1.13)
+ swr: 2.3.8(react@19.2.0)
+ throttleit: 2.1.0
+ optionalDependencies:
+ react: 19.2.0
+ zod: 4.1.13
+
'@ai-sdk/react@2.0.115(react@19.2.0)(zod@4.1.13)':
dependencies:
'@ai-sdk/provider-utils': 3.0.19(zod@4.1.13)
@@ -11345,6 +11688,14 @@ snapshots:
optionalDependencies:
zod: 4.1.13
+ '@ai-sdk/ui-utils@1.0.0(zod@4.1.13)':
+ dependencies:
+ '@ai-sdk/provider': 1.0.0
+ '@ai-sdk/provider-utils': 2.0.0(zod@4.1.13)
+ zod-to-json-schema: 3.25.1(zod@4.1.13)
+ optionalDependencies:
+ zod: 4.1.13
+
'@algolia/abtesting@1.12.0':
dependencies:
'@algolia/client-common': 5.46.0
@@ -12356,6 +12707,8 @@ snapshots:
'@braintree/sanitize-url@7.1.1': {}
+ '@cfworker/json-schema@4.1.1': {}
+
'@chevrotain/cst-dts-gen@11.0.3':
dependencies:
'@chevrotain/gast': 11.0.3
@@ -13628,23 +13981,12 @@ snapshots:
- uglify-js
- webpack-cli
- '@emnapi/core@1.7.1':
- dependencies:
- '@emnapi/wasi-threads': 1.1.0
- tslib: 2.8.1
- optional: true
-
'@emnapi/core@1.8.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
tslib: 2.8.1
optional: true
- '@emnapi/runtime@1.7.1':
- dependencies:
- tslib: 2.8.1
- optional: true
-
'@emnapi/runtime@1.8.1':
dependencies:
tslib: 2.8.1
@@ -14054,6 +14396,26 @@ snapshots:
'@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1)
tslib: 2.8.1
+ '@langchain/core@1.1.34(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(ws@8.18.3(bufferutil@4.0.9))':
+ dependencies:
+ '@cfworker/json-schema': 4.1.1
+ '@standard-schema/spec': 1.1.0
+ ansi-styles: 5.2.0
+ camelcase: 6.3.0
+ decamelize: 1.2.0
+ js-tiktoken: 1.0.21
+ langsmith: 0.5.11(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(ws@8.18.3(bufferutil@4.0.9))
+ mustache: 4.2.0
+ p-queue: 6.6.2
+ uuid: 11.1.0
+ zod: 4.1.13
+ transitivePeerDependencies:
+ - '@opentelemetry/api'
+ - '@opentelemetry/exporter-trace-otlp-proto'
+ - '@opentelemetry/sdk-trace-base'
+ - openai
+ - ws
+
'@leichtgewicht/ip-codec@2.0.5': {}
'@mdx-js/mdx@3.1.1':
@@ -14096,13 +14458,6 @@ snapshots:
dependencies:
langium: 3.3.1
- '@napi-rs/wasm-runtime@1.0.7':
- dependencies:
- '@emnapi/core': 1.7.1
- '@emnapi/runtime': 1.7.1
- '@tybys/wasm-util': 0.10.1
- optional: true
-
'@napi-rs/wasm-runtime@1.1.1':
dependencies:
'@emnapi/core': 1.8.1
@@ -15777,7 +16132,7 @@ snapshots:
'@rolldown/binding-wasm32-wasi@1.0.0-beta.41':
dependencies:
- '@napi-rs/wasm-runtime': 1.0.7
+ '@napi-rs/wasm-runtime': 1.1.1
optional: true
'@rolldown/binding-wasm32-wasi@1.0.0-rc.3':
@@ -16069,6 +16424,16 @@ snapshots:
dependencies:
defer-to-connect: 2.0.1
+ '@tailwindcss/node@4.1.17':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.18.3
+ jiti: 2.6.1
+ lightningcss: 1.30.2
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.1.17
+
'@tailwindcss/node@4.1.18':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -16079,42 +16444,93 @@ snapshots:
source-map-js: 1.2.1
tailwindcss: 4.1.18
+ '@tailwindcss/oxide-android-arm64@4.1.17':
+ optional: true
+
'@tailwindcss/oxide-android-arm64@4.1.18':
optional: true
+ '@tailwindcss/oxide-darwin-arm64@4.1.17':
+ optional: true
+
'@tailwindcss/oxide-darwin-arm64@4.1.18':
optional: true
+ '@tailwindcss/oxide-darwin-x64@4.1.17':
+ optional: true
+
'@tailwindcss/oxide-darwin-x64@4.1.18':
optional: true
+ '@tailwindcss/oxide-freebsd-x64@4.1.17':
+ optional: true
+
'@tailwindcss/oxide-freebsd-x64@4.1.18':
optional: true
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17':
+ optional: true
+
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
optional: true
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.17':
+ optional: true
+
'@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
optional: true
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.17':
+ optional: true
+
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
optional: true
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.17':
+ optional: true
+
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
optional: true
+ '@tailwindcss/oxide-linux-x64-musl@4.1.17':
+ optional: true
+
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
optional: true
+ '@tailwindcss/oxide-wasm32-wasi@4.1.17':
+ optional: true
+
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
optional: true
+ '@tailwindcss/oxide-win32-arm64-msvc@4.1.17':
+ optional: true
+
'@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
optional: true
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.17':
+ optional: true
+
'@tailwindcss/oxide-win32-x64-msvc@4.1.18':
optional: true
+ '@tailwindcss/oxide@4.1.17':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.1.17
+ '@tailwindcss/oxide-darwin-arm64': 4.1.17
+ '@tailwindcss/oxide-darwin-x64': 4.1.17
+ '@tailwindcss/oxide-freebsd-x64': 4.1.17
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.17
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.1.17
+ '@tailwindcss/oxide-linux-arm64-musl': 4.1.17
+ '@tailwindcss/oxide-linux-x64-gnu': 4.1.17
+ '@tailwindcss/oxide-linux-x64-musl': 4.1.17
+ '@tailwindcss/oxide-wasm32-wasi': 4.1.17
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.1.17
+ '@tailwindcss/oxide-win32-x64-msvc': 4.1.17
+
'@tailwindcss/oxide@4.1.18':
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.1.18
@@ -16130,6 +16546,14 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
'@tailwindcss/oxide-win32-x64-msvc': 4.1.18
+ '@tailwindcss/postcss@4.1.17':
+ dependencies:
+ '@alloc/quick-lru': 5.2.0
+ '@tailwindcss/node': 4.1.17
+ '@tailwindcss/oxide': 4.1.17
+ postcss: 8.5.6
+ tailwindcss: 4.1.17
+
'@tailwindcss/postcss@4.1.18':
dependencies:
'@alloc/quick-lru': 5.2.0
@@ -16358,6 +16782,8 @@ snapshots:
'@types/deep-eql@4.0.2': {}
+ '@types/diff-match-patch@1.0.36': {}
+
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
@@ -16588,6 +17014,8 @@ snapshots:
'@types/unist@3.0.3': {}
+ '@types/uuid@10.0.0': {}
+
'@types/validator@13.15.10': {}
'@types/ws@8.18.1':
@@ -16707,6 +17135,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@vitejs/plugin-react@5.1.1(rolldown-vite@7.1.14(@types/node@24.10.1)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2))':
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5)
+ '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5)
+ '@rolldown/pluginutils': 1.0.0-beta.47
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.18.0
+ vite: rolldown-vite@7.1.14(@types/node@24.10.1)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2)
+ transitivePeerDependencies:
+ - supports-color
+
'@vitejs/plugin-react@5.1.1(rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2))':
dependencies:
'@babel/core': 7.28.5
@@ -16908,6 +17348,19 @@ snapshots:
clean-stack: 2.2.0
indent-string: 4.0.0
+ ai@4.0.0(react@19.2.0)(zod@4.1.13):
+ dependencies:
+ '@ai-sdk/provider': 1.0.0
+ '@ai-sdk/provider-utils': 2.0.0(zod@4.1.13)
+ '@ai-sdk/react': 1.0.0(react@19.2.0)(zod@4.1.13)
+ '@ai-sdk/ui-utils': 1.0.0(zod@4.1.13)
+ '@opentelemetry/api': 1.9.0
+ jsondiffpatch: 0.6.0
+ zod-to-json-schema: 3.25.1(zod@4.1.13)
+ optionalDependencies:
+ react: 19.2.0
+ zod: 4.1.13
+
ai@5.0.113(zod@4.1.13):
dependencies:
'@ai-sdk/gateway': 2.0.21(zod@4.1.13)
@@ -17066,6 +17519,16 @@ snapshots:
dependencies:
immediate: 3.3.0
+ autoprefixer@10.4.21(postcss@8.5.6):
+ dependencies:
+ browserslist: 4.28.1
+ caniuse-lite: 1.0.30001760
+ fraction.js: 4.3.7
+ normalize-range: 0.1.2
+ picocolors: 1.1.1
+ postcss: 8.5.6
+ postcss-value-parser: 4.2.0
+
autoprefixer@10.4.23(postcss@8.5.6):
dependencies:
browserslist: 4.28.1
@@ -17571,6 +18034,10 @@ snapshots:
console-control-strings@1.1.0: {}
+ console-table-printer@2.15.0:
+ dependencies:
+ simple-wcswidth: 1.1.2
+
content-disposition@0.5.2: {}
content-disposition@0.5.4:
@@ -17792,7 +18259,7 @@ snapshots:
cssnano-preset-advanced@6.1.2(postcss@8.5.6):
dependencies:
- autoprefixer: 10.4.23(postcss@8.5.6)
+ autoprefixer: 10.4.21(postcss@8.5.6)
browserslist: 4.28.1
cssnano-preset-default: 6.1.2(postcss@8.5.6)
postcss: 8.5.6
@@ -18070,6 +18537,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decamelize@1.2.0: {}
+
decimal.js-light@2.5.1: {}
decimal.js@10.6.0: {}
@@ -18156,6 +18625,8 @@ snapshots:
dependencies:
dequal: 2.0.3
+ diff-match-patch@1.0.5: {}
+
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0
@@ -18783,6 +19254,8 @@ snapshots:
forwarded@0.2.0: {}
+ fraction.js@4.3.7: {}
+
fraction.js@5.3.4: {}
fresh@0.5.2: {}
@@ -19787,6 +20260,10 @@ snapshots:
'@sideway/formula': 3.0.1
'@sideway/pinpoint': 2.0.0
+ js-tiktoken@1.0.21:
+ dependencies:
+ base64-js: 1.5.1
+
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@@ -19862,6 +20339,12 @@ snapshots:
json5@2.2.3: {}
+ jsondiffpatch@0.6.0:
+ dependencies:
+ '@types/diff-match-patch': 1.0.36
+ chalk: 5.6.2
+ diff-match-patch: 1.0.5
+
jsonfile@6.2.0:
dependencies:
universalify: 2.0.1
@@ -19921,6 +20404,20 @@ snapshots:
vscode-languageserver-textdocument: 1.0.12
vscode-uri: 3.0.8
+ langsmith@0.5.11(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(ws@8.18.3(bufferutil@4.0.9)):
+ dependencies:
+ '@types/uuid': 10.0.0
+ chalk: 5.6.2
+ console-table-printer: 2.15.0
+ p-queue: 6.6.2
+ semver: 7.7.3
+ uuid: 10.0.0
+ optionalDependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/exporter-trace-otlp-proto': 0.208.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0)
+ ws: 8.18.3(bufferutil@4.0.9)
+
latest-version@7.0.0:
dependencies:
package-json: 8.1.1
@@ -20113,6 +20610,10 @@ snapshots:
lru-cache@7.18.3: {}
+ lucide-react@0.511.0(react@19.2.0):
+ dependencies:
+ react: 19.2.0
+
lucide-react@0.554.0(react@19.2.0):
dependencies:
react: 19.2.0
@@ -20158,6 +20659,8 @@ snapshots:
markdown-table@3.0.4: {}
+ marked@15.0.12: {}
+
marked@16.4.2: {}
marked@17.0.3: {}
@@ -20786,10 +21289,14 @@ snapshots:
dns-packet: 5.6.1
thunky: 1.1.0
+ mustache@4.2.0: {}
+
mute-stream@2.0.0: {}
nanoid@3.3.11: {}
+ nanoid@5.1.7: {}
+
natural-compare@1.4.0: {}
negotiator@0.6.3: {}
@@ -20854,6 +21361,8 @@ snapshots:
normalize-path@3.0.0: {}
+ normalize-range@0.1.2: {}
+
normalize-url@8.1.0: {}
not@0.1.0: {}
@@ -22267,6 +22776,24 @@ snapshots:
tsx: 4.20.6
yaml: 2.8.2
+ rolldown-vite@7.1.14(@types/node@24.10.1)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2):
+ dependencies:
+ '@oxc-project/runtime': 0.92.0
+ fdir: 6.5.0(picomatch@4.0.3)
+ lightningcss: 1.30.2
+ picomatch: 4.0.3
+ postcss: 8.5.6
+ rolldown: 1.0.0-beta.41
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 24.10.1
+ esbuild: 0.25.10
+ fsevents: 2.3.3
+ jiti: 2.6.1
+ terser: 5.44.1
+ tsx: 4.20.6
+ yaml: 2.8.2
+
rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.2):
dependencies:
'@oxc-project/runtime': 0.92.0
@@ -22442,6 +22969,8 @@ snapshots:
extend-shallow: 2.0.1
kind-of: 6.0.3
+ secure-json-parse@2.7.0: {}
+
select-hose@2.0.0: {}
selfsigned@2.4.1:
@@ -22620,6 +23149,8 @@ snapshots:
signal-exit@4.1.0: {}
+ simple-wcswidth@1.1.2: {}
+
sirv@2.0.4:
dependencies:
'@polka/url': 1.0.0-next.29
@@ -23354,6 +23885,8 @@ snapshots:
utils-merge@1.0.1: {}
+ uuid@10.0.0: {}
+
uuid@11.1.0: {}
uuid@8.3.2: {}
@@ -23852,6 +24385,10 @@ snapshots:
yoctocolors@2.1.2: {}
+ zod-to-json-schema@3.25.1(zod@4.1.13):
+ dependencies:
+ zod: 4.1.13
+
zod-validation-error@4.0.2(zod@4.1.13):
dependencies:
zod: 4.1.13
From 7b50c98e6bd950b6858404d12e90f742d48aade7 Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Wed, 8 Apr 2026 11:10:58 +0200
Subject: [PATCH 03/11] feat(agent): add Responses API translation layer at
HTTP boundary
Add ResponseStreamEvent types alongside internal AgentEvent. The adapter
contract (AgentEvent) stays unchanged; a new AgentEventTranslator converts
to Responses API SSE format at the HTTP boundary. Both /api/agent/chat
and future /invocations emit the same wire format.
- Add Responses API types to shared/agent.ts (self-contained, no openai dep)
- Add AgentEventTranslator with stateful sequence_number/output_index tracking
- AppKit extension events (appkit.thinking, appkit.metadata) preserved
- Update dev-playground and agent-app frontends to parse new SSE format
- Fix knip config for optional peer deps used by agent adapters
Signed-off-by: MarioCadenas
---
apps/agent-app/src/App.tsx | 91 ++++---
.../client/src/routes/agent.route.tsx | 102 +++++---
knip.json | 4 +-
packages/appkit/src/plugins/agent/agent.ts | 21 +-
.../src/plugins/agent/event-translator.ts | 226 ++++++++++++++++++
packages/shared/src/agent.ts | 100 ++++++++
6 files changed, 475 insertions(+), 69 deletions(-)
create mode 100644 packages/appkit/src/plugins/agent/event-translator.ts
diff --git a/apps/agent-app/src/App.tsx b/apps/agent-app/src/App.tsx
index f8f03f4c..8fb07405 100644
--- a/apps/agent-app/src/App.tsx
+++ b/apps/agent-app/src/App.tsx
@@ -1,19 +1,27 @@
import { TooltipProvider } from "@databricks/appkit-ui/react";
-import { useCallback, useEffect, useRef, useState } from "react";
import { marked } from "marked";
+import { useCallback, useEffect, useRef, useState } from "react";
import "./App.css";
import { ThemeSelector } from "./components/theme-selector";
-interface AgentEvent {
+interface SSEEvent {
type: string;
+ delta?: string;
+ item_id?: string;
+ item?: {
+ type?: string;
+ id?: string;
+ call_id?: string;
+ name?: string;
+ arguments?: string;
+ output?: string;
+ status?: string;
+ };
content?: string;
- callId?: string;
- name?: string;
- args?: unknown;
- result?: unknown;
- error?: string;
- status?: string;
data?: Record;
+ error?: string;
+ sequence_number?: number;
+ output_index?: number;
}
interface ChatMessage {
@@ -24,7 +32,7 @@ interface ChatMessage {
export default function App() {
const [messages, setMessages] = useState([]);
- const [events, setEvents] = useState([]);
+ const [events, setEvents] = useState([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [threadId, setThreadId] = useState(null);
@@ -98,14 +106,14 @@ export default function App() {
const data = line.slice(6).trim();
if (!data || data === "[DONE]") continue;
try {
- const event: AgentEvent = JSON.parse(data);
+ const event: SSEEvent = JSON.parse(data);
setEvents((prev) => [...prev, event]);
- if (event.type === "metadata" && event.data?.threadId) {
+ if (event.type === "appkit.metadata" && event.data?.threadId) {
setThreadId(event.data.threadId as string);
}
- if (event.type === "message_delta" && event.content) {
- content += event.content;
+ if (event.type === "response.output_text.delta" && event.delta) {
+ content += event.delta;
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
@@ -238,22 +246,47 @@ export default function App() {
{events.length === 0 && (
Events will appear here
)}
- {events.map((event, i) => (
-
- {event.type}
-
- {event.type === "message_delta"
- ? event.content?.slice(0, 60)
- : event.type === "tool_call"
- ? `${event.name}(${JSON.stringify(event.args).slice(0, 40)})`
- : event.type === "tool_result"
- ? `${String(event.result).slice(0, 60)}`
- : event.type === "status"
- ? event.status
- : JSON.stringify(event).slice(0, 60)}
-
-
- ))}
+ {events.map((event, i) => {
+ let detail: string;
+ switch (event.type) {
+ case "response.output_text.delta":
+ detail = event.delta?.slice(0, 60) ?? "";
+ break;
+ case "response.output_item.added":
+ case "response.output_item.done":
+ detail =
+ event.item?.type === "function_call"
+ ? `${event.item.name}(${(event.item.arguments ?? "").slice(0, 40)})`
+ : event.item?.type === "function_call_output"
+ ? (event.item.output?.slice(0, 60) ?? "")
+ : (event.item?.status ?? event.item?.type ?? "");
+ break;
+ case "response.completed":
+ detail = "done";
+ break;
+ case "error":
+ detail = event.error ?? "unknown";
+ break;
+ case "appkit.metadata":
+ detail = JSON.stringify(event.data).slice(0, 60);
+ break;
+ case "appkit.thinking":
+ detail = event.content?.slice(0, 60) ?? "";
+ break;
+ default:
+ detail = JSON.stringify(event).slice(0, 60);
+ }
+ return (
+
+
+ {event.type
+ .replace("response.", "")
+ .replace("appkit.", "")}
+
+ {detail}
+
+ );
+ })}
diff --git a/apps/dev-playground/client/src/routes/agent.route.tsx b/apps/dev-playground/client/src/routes/agent.route.tsx
index cdebfc54..fa111622 100644
--- a/apps/dev-playground/client/src/routes/agent.route.tsx
+++ b/apps/dev-playground/client/src/routes/agent.route.tsx
@@ -6,16 +6,24 @@ export const Route = createFileRoute("/agent")({
component: AgentRoute,
});
-interface AgentEvent {
+interface SSEEvent {
type: string;
+ delta?: string;
+ item_id?: string;
+ item?: {
+ type?: string;
+ id?: string;
+ call_id?: string;
+ name?: string;
+ arguments?: string;
+ output?: string;
+ status?: string;
+ };
content?: string;
- callId?: string;
- name?: string;
- args?: unknown;
- result?: unknown;
- error?: string;
- status?: string;
data?: Record;
+ error?: string;
+ sequence_number?: number;
+ output_index?: number;
}
interface ChatMessage {
@@ -75,8 +83,11 @@ function useAutocomplete(enabled: boolean) {
if (!data || data === "[DONE]") continue;
try {
const event = JSON.parse(data);
- if (event.type === "message_delta" && event.content) {
- result += event.content;
+ if (
+ event.type === "response.output_text.delta" &&
+ event.delta
+ ) {
+ result += event.delta;
setSuggestion(result);
}
} catch {
@@ -197,15 +208,15 @@ function AgentRoute() {
if (!data || data === "[DONE]") continue;
try {
- const event: AgentEvent = JSON.parse(data);
+ const event: SSEEvent = JSON.parse(data);
setEvents((prev) => [...prev, event]);
- if (event.type === "metadata" && event.data?.threadId) {
+ if (event.type === "appkit.metadata" && event.data?.threadId) {
setThreadId(event.data.threadId as string);
}
- if (event.type === "message_delta" && event.content) {
- assistantContent += event.content;
+ if (event.type === "response.output_text.delta" && event.delta) {
+ assistantContent += event.delta;
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
@@ -404,27 +415,50 @@ function AgentRoute() {
Events will appear here
)}
- {events.map((event, i) => (
-
-
- {event.type}
-
-
- {event.type === "message_delta"
- ? event.content?.slice(0, 60)
- : event.type === "tool_call"
- ? `${event.name}(${JSON.stringify(event.args).slice(0, 40)})`
- : event.type === "tool_result"
- ? `${String(event.result).slice(0, 60)}`
- : event.type === "status"
- ? event.status
- : JSON.stringify(event).slice(0, 60)}
-
-
- ))}
+ {events.map((event, i) => {
+ let detail: string;
+ switch (event.type) {
+ case "response.output_text.delta":
+ detail = event.delta?.slice(0, 60) ?? "";
+ break;
+ case "response.output_item.added":
+ case "response.output_item.done":
+ detail =
+ event.item?.type === "function_call"
+ ? `${event.item.name}(${(event.item.arguments ?? "").slice(0, 40)})`
+ : event.item?.type === "function_call_output"
+ ? (event.item.output?.slice(0, 60) ?? "")
+ : (event.item?.status ?? event.item?.type ?? "");
+ break;
+ case "response.completed":
+ detail = "done";
+ break;
+ case "error":
+ detail = event.error ?? "unknown";
+ break;
+ case "appkit.metadata":
+ detail = JSON.stringify(event.data).slice(0, 60);
+ break;
+ case "appkit.thinking":
+ detail = event.content?.slice(0, 60) ?? "";
+ break;
+ default:
+ detail = JSON.stringify(event).slice(0, 60);
+ }
+ return (
+
+
+ {event.type
+ .replace("response.", "")
+ .replace("appkit.", "")}
+
+ {detail}
+
+ );
+ })}
diff --git a/knip.json b/knip.json
index fae5b9c1..23382c3f 100644
--- a/knip.json
+++ b/knip.json
@@ -7,7 +7,9 @@
"docs"
],
"workspaces": {
- "packages/appkit": {},
+ "packages/appkit": {
+ "ignoreDependencies": ["ai", "@ai-sdk/openai", "@langchain/core", "zod"]
+ },
"packages/appkit-ui": {}
},
"ignore": [
diff --git a/packages/appkit/src/plugins/agent/agent.ts b/packages/appkit/src/plugins/agent/agent.ts
index 0aa41bdb..a205be10 100644
--- a/packages/appkit/src/plugins/agent/agent.ts
+++ b/packages/appkit/src/plugins/agent/agent.ts
@@ -2,17 +2,18 @@ import { randomUUID } from "node:crypto";
import type express from "express";
import type {
AgentAdapter,
- AgentEvent,
AgentToolDefinition,
IAppRouter,
Message,
PluginPhase,
+ ResponseStreamEvent,
ToolProvider,
} from "shared";
import { createLogger } from "../../logging/logger";
import { Plugin, toPlugin } from "../../plugin";
import type { PluginManifest } from "../../registry";
import { agentStreamDefaults } from "./defaults";
+import { AgentEventTranslator } from "./event-translator";
import manifest from "./manifest.json";
import { InMemoryThreadStore } from "./thread-store";
import type { AgentPluginConfig, RegisteredAgent, ToolEntry } from "./types";
@@ -233,11 +234,17 @@ export class AgentPlugin extends Plugin {
const self = this;
- await this.executeStream(
+ await this.executeStream(
res,
async function* () {
+ const translator = new AgentEventTranslator();
try {
- yield { type: "metadata" as const, data: { threadId: thread.id } };
+ for (const evt of translator.translate({
+ type: "metadata",
+ data: { threadId: thread.id },
+ })) {
+ yield evt;
+ }
const stream = resolvedAgent.adapter.run(
{
@@ -258,7 +265,9 @@ export class AgentPlugin extends Plugin {
fullContent += event.content;
}
- yield event;
+ for (const translated of translator.translate(event)) {
+ yield translated;
+ }
}
if (fullContent) {
@@ -275,7 +284,9 @@ export class AgentPlugin extends Plugin {
);
}
- yield { type: "status" as const, status: "complete" as const };
+ for (const evt of translator.finalize()) {
+ yield evt;
+ }
} catch (error) {
if (signal.aborted) return;
logger.error("Agent chat error: %O", error);
diff --git a/packages/appkit/src/plugins/agent/event-translator.ts b/packages/appkit/src/plugins/agent/event-translator.ts
new file mode 100644
index 00000000..9fbbbe5f
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/event-translator.ts
@@ -0,0 +1,226 @@
+import { randomUUID } from "node:crypto";
+import type {
+ AgentEvent,
+ ResponseFunctionCallOutput,
+ ResponseFunctionToolCall,
+ ResponseOutputMessage,
+ ResponseStreamEvent,
+} from "shared";
+
+/**
+ * Translates internal AgentEvent stream into Responses API SSE events.
+ *
+ * Stateful: one instance per streaming request. Tracks sequence numbers,
+ * output indices, and message accumulation state.
+ */
+export class AgentEventTranslator {
+ private seqNum = 0;
+ private outputIndex = 0;
+ private messageId: string | null = null;
+ private messageText = "";
+
+ translate(event: AgentEvent): ResponseStreamEvent[] {
+ switch (event.type) {
+ case "message_delta":
+ return this.handleMessageDelta(event.content);
+ case "message":
+ return this.handleFullMessage(event.content);
+ case "tool_call":
+ return this.handleToolCall(event.callId, event.name, event.args);
+ case "tool_result":
+ return this.handleToolResult(event.callId, event.result, event.error);
+ case "thinking":
+ return [
+ {
+ type: "appkit.thinking",
+ content: event.content,
+ sequence_number: this.seqNum++,
+ },
+ ];
+ case "metadata":
+ return [
+ {
+ type: "appkit.metadata",
+ data: event.data,
+ sequence_number: this.seqNum++,
+ },
+ ];
+ case "status":
+ return this.handleStatus(event.status, event.error);
+ }
+ }
+
+ finalize(): ResponseStreamEvent[] {
+ const events: ResponseStreamEvent[] = [];
+
+ if (this.messageId) {
+ const doneItem: ResponseOutputMessage = {
+ type: "message",
+ id: this.messageId,
+ status: "completed",
+ role: "assistant",
+ content: [{ type: "output_text", text: this.messageText }],
+ };
+ events.push({
+ type: "response.output_item.done",
+ output_index: 0,
+ item: doneItem,
+ sequence_number: this.seqNum++,
+ });
+ }
+
+ events.push({
+ type: "response.completed",
+ sequence_number: this.seqNum++,
+ response: {},
+ });
+
+ return events;
+ }
+
+ private handleMessageDelta(content: string): ResponseStreamEvent[] {
+ const events: ResponseStreamEvent[] = [];
+ this.messageText += content;
+
+ if (!this.messageId) {
+ this.messageId = `msg_${randomUUID()}`;
+ const item: ResponseOutputMessage = {
+ type: "message",
+ id: this.messageId,
+ status: "in_progress",
+ role: "assistant",
+ content: [],
+ };
+ events.push({
+ type: "response.output_item.added",
+ output_index: 0,
+ item,
+ sequence_number: this.seqNum++,
+ });
+ }
+
+ events.push({
+ type: "response.output_text.delta",
+ item_id: this.messageId,
+ output_index: 0,
+ content_index: 0,
+ delta: content,
+ sequence_number: this.seqNum++,
+ });
+
+ return events;
+ }
+
+ private handleFullMessage(content: string): ResponseStreamEvent[] {
+ if (!this.messageId) {
+ this.messageId = `msg_${randomUUID()}`;
+ }
+ this.messageText = content;
+
+ const item: ResponseOutputMessage = {
+ type: "message",
+ id: this.messageId,
+ status: "completed",
+ role: "assistant",
+ content: [{ type: "output_text", text: content }],
+ };
+
+ return [
+ {
+ type: "response.output_item.added",
+ output_index: 0,
+ item,
+ sequence_number: this.seqNum++,
+ },
+ {
+ type: "response.output_item.done",
+ output_index: 0,
+ item,
+ sequence_number: this.seqNum++,
+ },
+ ];
+ }
+
+ private handleToolCall(
+ callId: string,
+ name: string,
+ args: unknown,
+ ): ResponseStreamEvent[] {
+ this.outputIndex++;
+ const item: ResponseFunctionToolCall = {
+ type: "function_call",
+ id: `fc_${randomUUID()}`,
+ call_id: callId,
+ name,
+ arguments: typeof args === "string" ? args : JSON.stringify(args),
+ };
+
+ return [
+ {
+ type: "response.output_item.added",
+ output_index: this.outputIndex,
+ item,
+ sequence_number: this.seqNum++,
+ },
+ {
+ type: "response.output_item.done",
+ output_index: this.outputIndex,
+ item,
+ sequence_number: this.seqNum++,
+ },
+ ];
+ }
+
+ private handleToolResult(
+ callId: string,
+ result: unknown,
+ error?: string,
+ ): ResponseStreamEvent[] {
+ this.outputIndex++;
+ const output =
+ error ?? (typeof result === "string" ? result : JSON.stringify(result));
+ const item: ResponseFunctionCallOutput = {
+ type: "function_call_output",
+ id: `fc_output_${randomUUID()}`,
+ call_id: callId,
+ output,
+ };
+
+ return [
+ {
+ type: "response.output_item.added",
+ output_index: this.outputIndex,
+ item,
+ sequence_number: this.seqNum++,
+ },
+ {
+ type: "response.output_item.done",
+ output_index: this.outputIndex,
+ item,
+ sequence_number: this.seqNum++,
+ },
+ ];
+ }
+
+ private handleStatus(status: string, error?: string): ResponseStreamEvent[] {
+ if (status === "error") {
+ return [
+ {
+ type: "error",
+ error: error ?? "Unknown error",
+ sequence_number: this.seqNum++,
+ },
+ {
+ type: "response.failed",
+ sequence_number: this.seqNum++,
+ },
+ ];
+ }
+
+ if (status === "complete") {
+ return this.finalize();
+ }
+
+ return [];
+ }
+}
diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts
index 545c4616..c4f76b29 100644
--- a/packages/shared/src/agent.ts
+++ b/packages/shared/src/agent.ts
@@ -88,6 +88,106 @@ export type AgentEvent =
}
| { type: "metadata"; data: Record };
+// ---------------------------------------------------------------------------
+// Responses API types (OpenAI-compatible wire format for HTTP boundary)
+// Self-contained — no openai package dependency.
+// ---------------------------------------------------------------------------
+
+export interface OutputTextContent {
+ type: "output_text";
+ text: string;
+}
+
+export interface ResponseOutputMessage {
+ type: "message";
+ id: string;
+ status: "in_progress" | "completed";
+ role: "assistant";
+ content: OutputTextContent[];
+}
+
+export interface ResponseFunctionToolCall {
+ type: "function_call";
+ id: string;
+ call_id: string;
+ name: string;
+ arguments: string;
+}
+
+export interface ResponseFunctionCallOutput {
+ type: "function_call_output";
+ id: string;
+ call_id: string;
+ output: string;
+}
+
+export type ResponseOutputItem =
+ | ResponseOutputMessage
+ | ResponseFunctionToolCall
+ | ResponseFunctionCallOutput;
+
+export interface ResponseOutputItemAddedEvent {
+ type: "response.output_item.added";
+ output_index: number;
+ item: ResponseOutputItem;
+ sequence_number: number;
+}
+
+export interface ResponseOutputItemDoneEvent {
+ type: "response.output_item.done";
+ output_index: number;
+ item: ResponseOutputItem;
+ sequence_number: number;
+}
+
+export interface ResponseTextDeltaEvent {
+ type: "response.output_text.delta";
+ item_id: string;
+ output_index: number;
+ content_index: number;
+ delta: string;
+ sequence_number: number;
+}
+
+export interface ResponseCompletedEvent {
+ type: "response.completed";
+ sequence_number: number;
+ response: Record;
+}
+
+export interface ResponseErrorEvent {
+ type: "error";
+ error: string;
+ sequence_number: number;
+}
+
+export interface ResponseFailedEvent {
+ type: "response.failed";
+ sequence_number: number;
+}
+
+export interface AppKitThinkingEvent {
+ type: "appkit.thinking";
+ content: string;
+ sequence_number: number;
+}
+
+export interface AppKitMetadataEvent {
+ type: "appkit.metadata";
+ data: Record;
+ sequence_number: number;
+}
+
+export type ResponseStreamEvent =
+ | ResponseOutputItemAddedEvent
+ | ResponseOutputItemDoneEvent
+ | ResponseTextDeltaEvent
+ | ResponseCompletedEvent
+ | ResponseErrorEvent
+ | ResponseFailedEvent
+ | AppKitThinkingEvent
+ | AppKitMetadataEvent;
+
// ---------------------------------------------------------------------------
// Adapter contract
// ---------------------------------------------------------------------------
From fce4e0e76c89dbf4ea842eb270b2b516f68db2aa Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Wed, 8 Apr 2026 11:21:59 +0200
Subject: [PATCH 04/11] feat(appkit): add FunctionTool, HostedTool types and
lightweight MCP client
Add explicit tool types alongside ToolProvider auto-discovery:
- FunctionTool: user-defined tools with JSON Schema + execute callback
- HostedTool: Genie, VectorSearch, custom/external MCP server configs
- AppKitMcpClient: zero-dependency MCP client using raw fetch + JSON-RPC 2.0
- Discriminated ToolEntry union (source: plugin | function | mcp)
- collectTools() handles all three sources with conflict resolution
- addTools() for post-setup FunctionTool addition (HostedTools at setup only)
- MCP client lifecycle managed in setup/shutdown
Signed-off-by: MarioCadenas
---
knip.json | 3 +-
packages/appkit/src/index.ts | 8 +-
packages/appkit/src/plugins/agent/agent.ts | 171 +++++++++++----
.../src/plugins/agent/tests/agent.test.ts | 15 +-
.../src/plugins/agent/tools/function-tool.ts | 33 +++
.../src/plugins/agent/tools/hosted-tools.ts | 83 ++++++++
.../appkit/src/plugins/agent/tools/index.ts | 7 +
.../src/plugins/agent/tools/mcp-client.ts | 201 ++++++++++++++++++
packages/appkit/src/plugins/agent/types.ts | 27 ++-
9 files changed, 491 insertions(+), 57 deletions(-)
create mode 100644 packages/appkit/src/plugins/agent/tools/function-tool.ts
create mode 100644 packages/appkit/src/plugins/agent/tools/hosted-tools.ts
create mode 100644 packages/appkit/src/plugins/agent/tools/index.ts
create mode 100644 packages/appkit/src/plugins/agent/tools/mcp-client.ts
diff --git a/knip.json b/knip.json
index 23382c3f..ce8dc313 100644
--- a/knip.json
+++ b/knip.json
@@ -8,7 +8,8 @@
],
"workspaces": {
"packages/appkit": {
- "ignoreDependencies": ["ai", "@ai-sdk/openai", "@langchain/core", "zod"]
+ "ignoreDependencies": ["ai", "@ai-sdk/openai", "@langchain/core", "zod"],
+ "entry": ["src/agents/*.ts"]
},
"packages/appkit-ui": {}
},
diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts
index 2b7dd455..ca767e4b 100644
--- a/packages/appkit/src/index.ts
+++ b/packages/appkit/src/index.ts
@@ -44,9 +44,9 @@ export {
export { getExecutionContext } from "./context";
export { createApp } from "./core";
export {
- createAgent,
type AgentHandle,
type CreateAgentConfig,
+ createAgent,
} from "./core/create-agent";
// Errors
export {
@@ -63,6 +63,12 @@ export {
// Plugin authoring
export { Plugin, type ToPlugin, toPlugin } from "./plugin";
export { agent, analytics, files, genie, lakebase, server } from "./plugins";
+export { isFunctionTool, isHostedTool } from "./plugins/agent/tools";
+export type {
+ AgentTool,
+ FunctionTool,
+ HostedTool,
+} from "./plugins/agent/types";
// Registry types and utilities for plugin manifests
export type {
ConfigSchema,
diff --git a/packages/appkit/src/plugins/agent/agent.ts b/packages/appkit/src/plugins/agent/agent.ts
index a205be10..8dc90124 100644
--- a/packages/appkit/src/plugins/agent/agent.ts
+++ b/packages/appkit/src/plugins/agent/agent.ts
@@ -16,6 +16,14 @@ import { agentStreamDefaults } from "./defaults";
import { AgentEventTranslator } from "./event-translator";
import manifest from "./manifest.json";
import { InMemoryThreadStore } from "./thread-store";
+import {
+ AppKitMcpClient,
+ type FunctionTool,
+ functionToolToDefinition,
+ isFunctionTool,
+ isHostedTool,
+ resolveHostedTools,
+} from "./tools";
import type { AgentPluginConfig, RegisteredAgent, ToolEntry } from "./types";
const logger = createLogger("agent");
@@ -42,6 +50,7 @@ export class AgentPlugin extends Plugin {
private toolIndex = new Map();
private threadStore;
private activeStreams = new Map();
+ private mcpClient: AppKitMcpClient | null = null;
constructor(config: AgentPluginConfig) {
super(config);
@@ -50,7 +59,7 @@ export class AgentPlugin extends Plugin {
}
async setup() {
- this.collectTools();
+ await this.collectTools();
if (this.config.agents) {
const entries = Object.entries(this.config.agents);
@@ -73,34 +82,107 @@ export class AgentPlugin extends Plugin {
}
}
- private collectTools() {
+ private async collectTools() {
+ // 1. Auto-discover from sibling ToolProvider plugins
const plugins = this.config.plugins;
- if (!plugins) return;
-
- for (const [pluginName, pluginInstance] of Object.entries(plugins)) {
- if (pluginName === "agent") continue;
- if (!isToolProvider(pluginInstance)) continue;
-
- const tools = (pluginInstance as ToolProvider).getAgentTools();
- for (const tool of tools) {
- const qualifiedName = `${pluginName}.${tool.name}`;
- this.toolIndex.set(qualifiedName, {
- plugin: pluginInstance as ToolProvider & { asUser(req: any): any },
- def: { ...tool, name: qualifiedName },
- localName: tool.name,
- });
+ if (plugins) {
+ for (const [pluginName, pluginInstance] of Object.entries(plugins)) {
+ if (pluginName === "agent") continue;
+ if (!isToolProvider(pluginInstance)) continue;
+
+ const tools = (pluginInstance as ToolProvider).getAgentTools();
+ for (const tool of tools) {
+ const qualifiedName = `${pluginName}.${tool.name}`;
+ this.toolIndex.set(qualifiedName, {
+ source: "plugin",
+ plugin: pluginInstance as ToolProvider & {
+ asUser(req: any): any;
+ },
+ def: { ...tool, name: qualifiedName },
+ localName: tool.name,
+ });
+ }
+
+ logger.info(
+ "Collected %d tools from plugin %s",
+ tools.length,
+ pluginName,
+ );
+ }
+ }
+
+ // 2. Process explicit tools from config
+ if (this.config.tools) {
+ const hostedTools = this.config.tools.filter(isHostedTool);
+ const functionTools = this.config.tools.filter(isFunctionTool);
+
+ // 2a. Resolve HostedTools via MCP client
+ if (hostedTools.length > 0) {
+ await this.connectHostedTools(hostedTools);
}
- logger.info(
- "Collected %d tools from plugin %s",
- tools.length,
- pluginName,
- );
+ // 2b. Add FunctionTools
+ for (const ft of functionTools) {
+ this.addFunctionToolToIndex(ft);
+ }
}
logger.info("Total agent tools: %d", this.toolIndex.size);
}
+ private async connectHostedTools(
+ hostedTools: import("./tools/hosted-tools").HostedTool[],
+ ) {
+ const host = process.env.DATABRICKS_HOST;
+ if (!host) {
+ logger.warn(
+ "DATABRICKS_HOST not set — skipping %d hosted tools",
+ hostedTools.length,
+ );
+ return;
+ }
+
+ this.mcpClient = new AppKitMcpClient(
+ host,
+ async (): Promise> => {
+ const token = process.env.DATABRICKS_TOKEN;
+ if (token) return { Authorization: `Bearer ${token}` };
+ return {};
+ },
+ );
+
+ const endpoints = resolveHostedTools(hostedTools);
+ await this.mcpClient.connectAll(endpoints);
+
+ for (const def of this.mcpClient.getAllToolDefinitions()) {
+ this.toolIndex.set(def.name, {
+ source: "mcp",
+ mcpToolName: def.name,
+ def,
+ });
+ }
+ }
+
+ private addFunctionToolToIndex(ft: FunctionTool) {
+ const def = functionToolToDefinition(ft);
+ this.toolIndex.set(ft.name, {
+ source: "function",
+ functionTool: ft,
+ def,
+ });
+ }
+
+ addTools(tools: FunctionTool[]) {
+ for (const ft of tools) {
+ this.addFunctionToolToIndex(ft);
+ }
+ logger.info(
+ "Added %d function tools, total: %d",
+ tools.length,
+ this.toolIndex.size,
+ );
+ }
+
injectRoutes(router: IAppRouter) {
this.route(router, {
name: "chat",
@@ -218,15 +300,24 @@ export class AgentPlugin extends Plugin {
const entry = this.toolIndex.get(qualifiedName);
if (!entry) throw new Error(`Unknown tool: ${qualifiedName}`);
- const target = entry.def.annotations?.requiresUserContext
- ? (entry.plugin as any).asUser(req)
- : entry.plugin;
-
- return (target as ToolProvider).executeAgentTool(
- entry.localName,
- args,
- signal,
- );
+ switch (entry.source) {
+ case "plugin": {
+ const target = entry.def.annotations?.requiresUserContext
+ ? (entry.plugin as any).asUser(req)
+ : entry.plugin;
+ return (target as ToolProvider).executeAgentTool(
+ entry.localName,
+ args,
+ signal,
+ );
+ }
+ case "function":
+ return entry.functionTool.execute(args as Record);
+ case "mcp": {
+ if (!this.mcpClient) throw new Error("MCP client not connected");
+ return this.mcpClient.callTool(entry.mcpToolName, args);
+ }
+ }
};
const requestId = randomUUID();
@@ -377,6 +468,13 @@ export class AgentPlugin extends Plugin {
return Array.from(this.toolIndex.values()).map((e) => e.def);
}
+ async shutdown() {
+ if (this.mcpClient) {
+ await this.mcpClient.close();
+ this.mcpClient = null;
+ }
+ }
+
exports() {
return {
registerAgent: (name: string, adapter: AgentAdapter) => {
@@ -385,18 +483,7 @@ export class AgentPlugin extends Plugin {
this.defaultAgentName = name;
}
},
- registerTool: (
- pluginName: string,
- tool: AgentToolDefinition,
- provider: ToolProvider & { asUser(req: any): any },
- ) => {
- const qualifiedName = `${pluginName}.${tool.name}`;
- this.toolIndex.set(qualifiedName, {
- plugin: provider,
- def: { ...tool, name: qualifiedName },
- localName: tool.name,
- });
- },
+ addTools: (tools: FunctionTool[]) => this.addTools(tools),
getTools: () => this.getAllToolDefinitions(),
getThreads: (userId: string) => this.threadStore.list(userId),
};
diff --git a/packages/appkit/src/plugins/agent/tests/agent.test.ts b/packages/appkit/src/plugins/agent/tests/agent.test.ts
index f67a10e1..5388ca40 100644
--- a/packages/appkit/src/plugins/agent/tests/agent.test.ts
+++ b/packages/appkit/src/plugins/agent/tests/agent.test.ts
@@ -128,22 +128,21 @@ describe("AgentPlugin", () => {
expect(handlers["GET:/tools"]).toBeDefined();
});
- test("exports().registerTool adds external tools", () => {
+ test("exports().addTools adds function tools", () => {
const plugin = new AgentPlugin({ name: "agent" });
- const provider = createMockToolProvider([]);
- plugin.exports().registerTool(
- "custom",
+ plugin.exports().addTools([
{
+ type: "function" as const,
name: "myTool",
description: "A custom tool",
- parameters: { type: "object" },
+ parameters: { type: "object", properties: {} },
+ execute: async () => "result",
},
- provider,
- );
+ ]);
const tools = plugin.exports().getTools();
expect(tools).toHaveLength(1);
- expect(tools[0].name).toBe("custom.myTool");
+ expect(tools[0].name).toBe("myTool");
});
});
diff --git a/packages/appkit/src/plugins/agent/tools/function-tool.ts b/packages/appkit/src/plugins/agent/tools/function-tool.ts
new file mode 100644
index 00000000..8ce634e0
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/tools/function-tool.ts
@@ -0,0 +1,33 @@
+import type { AgentToolDefinition } from "shared";
+
+export interface FunctionTool {
+ type: "function";
+ name: string;
+ description?: string | null;
+ parameters?: Record | null;
+ strict?: boolean | null;
+ execute: (args: Record) => Promise | string;
+}
+
+export function isFunctionTool(value: unknown): value is FunctionTool {
+ if (typeof value !== "object" || value === null) return false;
+ const obj = value as Record;
+ return (
+ obj.type === "function" &&
+ typeof obj.name === "string" &&
+ typeof obj.execute === "function"
+ );
+}
+
+export function functionToolToDefinition(
+ tool: FunctionTool,
+): AgentToolDefinition {
+ return {
+ name: tool.name,
+ description: tool.description ?? tool.name,
+ parameters: (tool.parameters as AgentToolDefinition["parameters"]) ?? {
+ type: "object",
+ properties: {},
+ },
+ };
+}
diff --git a/packages/appkit/src/plugins/agent/tools/hosted-tools.ts b/packages/appkit/src/plugins/agent/tools/hosted-tools.ts
new file mode 100644
index 00000000..23942ae7
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/tools/hosted-tools.ts
@@ -0,0 +1,83 @@
+export interface GenieTool {
+ type: "genie-space";
+ genie_space: { id: string };
+}
+
+export interface VectorSearchIndexTool {
+ type: "vector_search_index";
+ vector_search_index: { name: string };
+}
+
+export interface CustomMcpServerTool {
+ type: "custom_mcp_server";
+ custom_mcp_server: { app_name: string; app_url: string };
+}
+
+export interface ExternalMcpServerTool {
+ type: "external_mcp_server";
+ external_mcp_server: { connection_name: string };
+}
+
+export type HostedTool =
+ | GenieTool
+ | VectorSearchIndexTool
+ | CustomMcpServerTool
+ | ExternalMcpServerTool;
+
+const HOSTED_TOOL_TYPES = new Set([
+ "genie-space",
+ "vector_search_index",
+ "custom_mcp_server",
+ "external_mcp_server",
+]);
+
+export function isHostedTool(value: unknown): value is HostedTool {
+ if (typeof value !== "object" || value === null) return false;
+ const obj = value as Record;
+ return typeof obj.type === "string" && HOSTED_TOOL_TYPES.has(obj.type);
+}
+
+export interface McpEndpointConfig {
+ name: string;
+ path: string;
+}
+
+/**
+ * Resolves HostedTool configs into MCP endpoint configurations
+ * that the MCP client can connect to.
+ */
+function resolveHostedTool(tool: HostedTool): McpEndpointConfig {
+ switch (tool.type) {
+ case "genie-space":
+ return {
+ name: `genie-${tool.genie_space.id}`,
+ path: `/api/2.0/mcp/genie/${tool.genie_space.id}`,
+ };
+ case "vector_search_index": {
+ const parts = tool.vector_search_index.name.split(".");
+ if (parts.length !== 3) {
+ throw new Error(
+ `vector_search_index name must be 3-part dotted (catalog.schema.index), got: ${tool.vector_search_index.name}`,
+ );
+ }
+ return {
+ name: `vs-${parts.join("-")}`,
+ path: `/api/2.0/mcp/vector-search/${parts[0]}/${parts[1]}/${parts[2]}`,
+ };
+ }
+ case "custom_mcp_server":
+ return {
+ name: tool.custom_mcp_server.app_name,
+ path: `/apps/${tool.custom_mcp_server.app_url}`,
+ };
+ case "external_mcp_server":
+ return {
+ name: tool.external_mcp_server.connection_name,
+ path: `/api/2.0/mcp/connections/${tool.external_mcp_server.connection_name}`,
+ };
+ }
+}
+
+export function resolveHostedTools(tools: HostedTool[]): McpEndpointConfig[] {
+ return tools.map(resolveHostedTool);
+}
diff --git a/packages/appkit/src/plugins/agent/tools/index.ts b/packages/appkit/src/plugins/agent/tools/index.ts
new file mode 100644
index 00000000..0e8d2194
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/tools/index.ts
@@ -0,0 +1,7 @@
+export {
+ type FunctionTool,
+ functionToolToDefinition,
+ isFunctionTool,
+} from "./function-tool";
+export { isHostedTool, resolveHostedTools } from "./hosted-tools";
+export { AppKitMcpClient } from "./mcp-client";
diff --git a/packages/appkit/src/plugins/agent/tools/mcp-client.ts b/packages/appkit/src/plugins/agent/tools/mcp-client.ts
new file mode 100644
index 00000000..8ac58788
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/tools/mcp-client.ts
@@ -0,0 +1,201 @@
+import type { AgentToolDefinition } from "shared";
+import { createLogger } from "../../../logging/logger";
+import type { McpEndpointConfig } from "./hosted-tools";
+
+const logger = createLogger("agent:mcp");
+
+interface JsonRpcRequest {
+ jsonrpc: "2.0";
+ id: number;
+ method: string;
+ params?: Record;
+}
+
+interface JsonRpcResponse {
+ jsonrpc: "2.0";
+ id: number;
+ result?: unknown;
+ error?: { code: number; message: string; data?: unknown };
+}
+
+interface McpToolSchema {
+ name: string;
+ description?: string;
+ inputSchema?: Record;
+}
+
+interface McpToolCallResult {
+ content: Array<{ type: string; text?: string }>;
+ isError?: boolean;
+}
+
+interface McpServerConnection {
+ config: McpEndpointConfig;
+ tools: Map;
+}
+
+/**
+ * Lightweight MCP client for Databricks-hosted MCP servers.
+ *
+ * Uses raw fetch() with JSON-RPC 2.0 over HTTP — no @modelcontextprotocol/sdk
+ * or LangChain dependency. Supports the Streamable HTTP transport (POST with
+ * JSON-RPC request, single JSON-RPC response).
+ */
+export class AppKitMcpClient {
+ private connections = new Map();
+ private requestId = 0;
+ private closed = false;
+
+ constructor(
+ private workspaceHost: string,
+ private authenticate: () => Promise>,
+ ) {}
+
+ async connectAll(endpoints: McpEndpointConfig[]): Promise {
+ await Promise.all(endpoints.map((ep) => this.connect(ep)));
+ }
+
+ async connect(endpoint: McpEndpointConfig): Promise {
+ logger.info(
+ "Connecting to MCP server: %s at %s",
+ endpoint.name,
+ endpoint.path,
+ );
+
+ await this.sendRpc(endpoint.path, "initialize", {
+ protocolVersion: "2025-03-26",
+ capabilities: {},
+ clientInfo: { name: "appkit-agent", version: "0.1.0" },
+ });
+
+ await this.sendNotification(endpoint.path, "notifications/initialized");
+
+ const result = await this.sendRpc(endpoint.path, "tools/list", {});
+ const toolList = (result as { tools?: McpToolSchema[] })?.tools ?? [];
+
+ const tools = new Map();
+ for (const tool of toolList) {
+ tools.set(tool.name, tool);
+ }
+
+ this.connections.set(endpoint.name, { config: endpoint, tools });
+ logger.info(
+ "Connected to MCP server %s: %d tools available",
+ endpoint.name,
+ tools.size,
+ );
+ }
+
+ getAllToolDefinitions(): AgentToolDefinition[] {
+ const defs: AgentToolDefinition[] = [];
+ for (const [serverName, conn] of this.connections) {
+ for (const [toolName, schema] of conn.tools) {
+ defs.push({
+ name: `mcp.${serverName}.${toolName}`,
+ description: schema.description ?? toolName,
+ parameters:
+ (schema.inputSchema as AgentToolDefinition["parameters"]) ?? {
+ type: "object",
+ properties: {},
+ },
+ });
+ }
+ }
+ return defs;
+ }
+
+ async callTool(qualifiedName: string, args: unknown): Promise {
+ const parts = qualifiedName.split(".");
+ if (parts.length < 3 || parts[0] !== "mcp") {
+ throw new Error(`Invalid MCP tool name: ${qualifiedName}`);
+ }
+ const serverName = parts[1];
+ const toolName = parts.slice(2).join(".");
+
+ const conn = this.connections.get(serverName);
+ if (!conn) {
+ throw new Error(`MCP server not connected: ${serverName}`);
+ }
+
+ const result = (await this.sendRpc(conn.config.path, "tools/call", {
+ name: toolName,
+ arguments: args,
+ })) as McpToolCallResult;
+
+ if (result.isError) {
+ const errText = result.content
+ .filter((c) => c.type === "text")
+ .map((c) => c.text)
+ .join("\n");
+ throw new Error(errText || "MCP tool call failed");
+ }
+
+ return result.content
+ .filter((c) => c.type === "text")
+ .map((c) => c.text)
+ .join("\n");
+ }
+
+ async close(): Promise {
+ this.closed = true;
+ this.connections.clear();
+ }
+
+ private async sendRpc(
+ path: string,
+ method: string,
+ params?: Record,
+ ): Promise {
+ if (this.closed) throw new Error("MCP client is closed");
+
+ const request: JsonRpcRequest = {
+ jsonrpc: "2.0",
+ id: ++this.requestId,
+ method,
+ ...(params && { params }),
+ };
+
+ const url = `${this.workspaceHost}${path}`;
+ const authHeaders = await this.authenticate();
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ ...authHeaders,
+ },
+ body: JSON.stringify(request),
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `MCP request to ${method} failed: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ const json = (await response.json()) as JsonRpcResponse;
+ if (json.error) {
+ throw new Error(`MCP error (${json.error.code}): ${json.error.message}`);
+ }
+
+ return json.result;
+ }
+
+ private async sendNotification(path: string, method: string): Promise {
+ if (this.closed) return;
+
+ const url = `${this.workspaceHost}${path}`;
+ const authHeaders = await this.authenticate();
+
+ await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ ...authHeaders,
+ },
+ body: JSON.stringify({ jsonrpc: "2.0", method }),
+ });
+ }
+}
diff --git a/packages/appkit/src/plugins/agent/types.ts b/packages/appkit/src/plugins/agent/types.ts
index 51a18189..e86242a1 100644
--- a/packages/appkit/src/plugins/agent/types.ts
+++ b/packages/appkit/src/plugins/agent/types.ts
@@ -5,19 +5,36 @@ import type {
ThreadStore,
ToolProvider,
} from "shared";
+import type { FunctionTool } from "./tools/function-tool";
+import type { HostedTool } from "./tools/hosted-tools";
+
+export type AgentTool = FunctionTool | HostedTool;
export interface AgentPluginConfig extends BasePluginConfig {
agents?: Record>;
defaultAgent?: string;
threadStore?: ThreadStore;
+ tools?: AgentTool[];
plugins?: Record;
}
-export interface ToolEntry {
- plugin: ToolProvider & { asUser(req: any): any };
- def: AgentToolDefinition;
- localName: string;
-}
+export type ToolEntry =
+ | {
+ source: "plugin";
+ plugin: ToolProvider & { asUser(req: any): any };
+ def: AgentToolDefinition;
+ localName: string;
+ }
+ | {
+ source: "function";
+ functionTool: FunctionTool;
+ def: AgentToolDefinition;
+ }
+ | {
+ source: "mcp";
+ mcpToolName: string;
+ def: AgentToolDefinition;
+ };
export type RegisteredAgent = {
name: string;
From 010562587d8e4dc1fb97ebc1fa8f26d94097cf39 Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Wed, 8 Apr 2026 11:29:32 +0200
Subject: [PATCH 05/11] feat(appkit): route all tool execution through
interceptor chain
Wrap executeTool callback with this.execute() so all tool invocations
(ToolProvider plugins, FunctionTool, MCP) get uniform telemetry tracing
and 30s timeout via AppKit's interceptor chain. OBO-aware execution
via asUser(req) is preserved for ToolProvider tools.
Also fix main package exports for FunctionTool and HostedTool types.
Signed-off-by: MarioCadenas
---
packages/appkit/src/index.ts | 8 ++--
packages/appkit/src/plugins/agent/agent.ts | 55 +++++++++++++---------
2 files changed, 37 insertions(+), 26 deletions(-)
diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts
index ca767e4b..0d1187d2 100644
--- a/packages/appkit/src/index.ts
+++ b/packages/appkit/src/index.ts
@@ -64,11 +64,9 @@ export {
export { Plugin, type ToPlugin, toPlugin } from "./plugin";
export { agent, analytics, files, genie, lakebase, server } from "./plugins";
export { isFunctionTool, isHostedTool } from "./plugins/agent/tools";
-export type {
- AgentTool,
- FunctionTool,
- HostedTool,
-} from "./plugins/agent/types";
+export type { FunctionTool } from "./plugins/agent/tools/function-tool";
+export type { HostedTool } from "./plugins/agent/tools/hosted-tools";
+export type { AgentTool } from "./plugins/agent/types";
// Registry types and utilities for plugin manifests
export type {
ConfigSchema,
diff --git a/packages/appkit/src/plugins/agent/agent.ts b/packages/appkit/src/plugins/agent/agent.ts
index 8dc90124..93f7210b 100644
--- a/packages/appkit/src/plugins/agent/agent.ts
+++ b/packages/appkit/src/plugins/agent/agent.ts
@@ -293,38 +293,51 @@ export class AgentPlugin extends Plugin {
const abortController = new AbortController();
const signal = abortController.signal;
+ const self = this;
const executeTool = async (
qualifiedName: string,
args: unknown,
): Promise => {
- const entry = this.toolIndex.get(qualifiedName);
+ const entry = self.toolIndex.get(qualifiedName);
if (!entry) throw new Error(`Unknown tool: ${qualifiedName}`);
- switch (entry.source) {
- case "plugin": {
- const target = entry.def.annotations?.requiresUserContext
- ? (entry.plugin as any).asUser(req)
- : entry.plugin;
- return (target as ToolProvider).executeAgentTool(
- entry.localName,
- args,
- signal,
- );
- }
- case "function":
- return entry.functionTool.execute(args as Record);
- case "mcp": {
- if (!this.mcpClient) throw new Error("MCP client not connected");
- return this.mcpClient.callTool(entry.mcpToolName, args);
- }
- }
+ return self.execute(
+ async (execSignal) => {
+ switch (entry.source) {
+ case "plugin": {
+ const target = entry.def.annotations?.requiresUserContext
+ ? (entry.plugin as any).asUser(req)
+ : entry.plugin;
+ return (target as ToolProvider).executeAgentTool(
+ entry.localName,
+ args,
+ execSignal,
+ );
+ }
+ case "function":
+ return entry.functionTool.execute(
+ args as Record,
+ );
+ case "mcp": {
+ if (!self.mcpClient) {
+ throw new Error("MCP client not connected");
+ }
+ return self.mcpClient.callTool(entry.mcpToolName, args);
+ }
+ }
+ },
+ {
+ default: {
+ telemetryInterceptor: { enabled: true },
+ timeout: 30_000,
+ },
+ },
+ );
};
const requestId = randomUUID();
this.activeStreams.set(requestId, abortController);
- const self = this;
-
await this.executeStream(
res,
async function* () {
From ed184a2713a3e6d3183e0003440ff79f64da5788 Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Wed, 8 Apr 2026 11:31:50 +0200
Subject: [PATCH 06/11] feat(appkit): mount POST /invocations via
server.extend()
Agent plugin accesses the server plugin from config.plugins (deferred phase)
and calls server.extend() to mount POST /invocations at the app root.
- Accepts Responses API request format ({ input, stream?, model? })
- Extracts user message from input array or string
- Delegates to the same internal chat handler flow
- No Plugin base class changes needed
Signed-off-by: MarioCadenas
---
packages/appkit/src/plugins/agent/agent.ts | 52 ++++++++++++++++++++++
1 file changed, 52 insertions(+)
diff --git a/packages/appkit/src/plugins/agent/agent.ts b/packages/appkit/src/plugins/agent/agent.ts
index 93f7210b..ebb4e4bd 100644
--- a/packages/appkit/src/plugins/agent/agent.ts
+++ b/packages/appkit/src/plugins/agent/agent.ts
@@ -80,6 +80,27 @@ export class AgentPlugin extends Plugin {
if (this.config.defaultAgent) {
this.defaultAgentName = this.config.defaultAgent;
}
+
+ this.mountInvocationsRoute();
+ }
+
+ private mountInvocationsRoute() {
+ const serverPlugin = this.config.plugins?.server as
+ | { extend?: (fn: (app: any) => void) => void }
+ | undefined;
+
+ if (!serverPlugin?.extend) return;
+
+ serverPlugin.extend((app: import("express").Application) => {
+ app.post(
+ "/invocations",
+ (req: express.Request, res: express.Response) => {
+ this._handleInvocations(req, res);
+ },
+ );
+ });
+
+ logger.info("Mounted POST /invocations route");
}
private async collectTools() {
@@ -409,6 +430,37 @@ export class AgentPlugin extends Plugin {
);
}
+ private async _handleInvocations(
+ req: express.Request,
+ res: express.Response,
+ ): Promise {
+ const body = req.body as {
+ input?: string | Array<{ role?: string; content?: string }>;
+ stream?: boolean;
+ model?: string;
+ };
+
+ if (!body.input) {
+ res.status(400).json({ error: "input is required" });
+ return;
+ }
+
+ let userMessage: string;
+ if (typeof body.input === "string") {
+ userMessage = body.input;
+ } else {
+ const last = [...body.input].reverse().find((m) => m.role === "user");
+ if (!last?.content) {
+ res.status(400).json({ error: "No user message found in input" });
+ return;
+ }
+ userMessage = last.content;
+ }
+
+ req.body = { message: userMessage };
+ return this._handleChat(req, res);
+ }
+
private async _handleCancel(
req: express.Request,
res: express.Response,
From 99e57bf752e717cdf7fbf5e2fa2c64f7d3e40e8a Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Wed, 8 Apr 2026 11:35:43 +0200
Subject: [PATCH 07/11] feat(appkit): add Zod request validation and error
handling
Add Zod schemas for both endpoint request formats:
- /api/agent/chat: chatRequestSchema (message, threadId?, agent?)
- /invocations: invocationsRequestSchema (input, stream?, model?)
- Structured 400 error responses with field-level details
- Replace manual type assertions with safe parsing
Signed-off-by: MarioCadenas
---
packages/appkit/src/plugins/agent/agent.ts | 48 ++++++++++----------
packages/appkit/src/plugins/agent/schemas.ts | 19 ++++++++
2 files changed, 42 insertions(+), 25 deletions(-)
create mode 100644 packages/appkit/src/plugins/agent/schemas.ts
diff --git a/packages/appkit/src/plugins/agent/agent.ts b/packages/appkit/src/plugins/agent/agent.ts
index ebb4e4bd..43d43882 100644
--- a/packages/appkit/src/plugins/agent/agent.ts
+++ b/packages/appkit/src/plugins/agent/agent.ts
@@ -15,6 +15,7 @@ import type { PluginManifest } from "../../registry";
import { agentStreamDefaults } from "./defaults";
import { AgentEventTranslator } from "./event-translator";
import manifest from "./manifest.json";
+import { chatRequestSchema, invocationsRequestSchema } from "./schemas";
import { InMemoryThreadStore } from "./thread-store";
import {
AppKitMcpClient,
@@ -264,21 +265,17 @@ export class AgentPlugin extends Plugin {
req: express.Request,
res: express.Response,
): Promise {
- const {
- message,
- threadId,
- agent: agentName,
- } = req.body as {
- message?: string;
- threadId?: string;
- agent?: string;
- };
-
- if (!message) {
- res.status(400).json({ error: "message is required" });
+ const parsed = chatRequestSchema.safeParse(req.body);
+ if (!parsed.success) {
+ res.status(400).json({
+ error: "Invalid request",
+ details: parsed.error.flatten().fieldErrors,
+ });
return;
}
+ const { message, threadId, agent: agentName } = parsed.data;
+
const resolvedAgent = this.resolveAgent(agentName);
if (!resolvedAgent) {
res.status(400).json({
@@ -434,27 +431,28 @@ export class AgentPlugin extends Plugin {
req: express.Request,
res: express.Response,
): Promise {
- const body = req.body as {
- input?: string | Array<{ role?: string; content?: string }>;
- stream?: boolean;
- model?: string;
- };
-
- if (!body.input) {
- res.status(400).json({ error: "input is required" });
+ const parsed = invocationsRequestSchema.safeParse(req.body);
+ if (!parsed.success) {
+ res.status(400).json({
+ error: "Invalid request",
+ details: parsed.error.flatten().fieldErrors,
+ });
return;
}
+ const { input } = parsed.data;
+
let userMessage: string;
- if (typeof body.input === "string") {
- userMessage = body.input;
+ if (typeof input === "string") {
+ userMessage = input;
} else {
- const last = [...body.input].reverse().find((m) => m.role === "user");
- if (!last?.content) {
+ const last = [...input].reverse().find((m) => m.role === "user");
+ const content = last?.content;
+ if (!content || typeof content !== "string") {
res.status(400).json({ error: "No user message found in input" });
return;
}
- userMessage = last.content;
+ userMessage = content;
}
req.body = { message: userMessage };
diff --git a/packages/appkit/src/plugins/agent/schemas.ts b/packages/appkit/src/plugins/agent/schemas.ts
new file mode 100644
index 00000000..84ab3b88
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/schemas.ts
@@ -0,0 +1,19 @@
+import { z } from "zod";
+
+export const chatRequestSchema = z.object({
+ message: z.string().min(1, "message must not be empty"),
+ threadId: z.string().optional(),
+ agent: z.string().optional(),
+});
+
+const messageItemSchema = z.object({
+ role: z.enum(["user", "assistant", "system"]).optional(),
+ content: z.union([z.string(), z.array(z.any())]).optional(),
+ type: z.string().optional(),
+});
+
+export const invocationsRequestSchema = z.object({
+ input: z.union([z.string().min(1), z.array(messageItemSchema).min(1)]),
+ stream: z.boolean().optional().default(true),
+ model: z.string().optional(),
+});
From 9eeda0198cb943c87e9204bf2ae33bd5683f0d35 Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Wed, 8 Apr 2026 11:39:11 +0200
Subject: [PATCH 08/11] feat(appkit): enrich plugin exports and add
comprehensive tests
Add getAgents() to plugin exports and comprehensive test coverage:
- event-translator tests: 13 tests covering all AgentEvent -> ResponseStreamEvent
translations, sequence_number monotonicity, output_index tracking
- function-tool tests: 11 tests for isFunctionTool guard and
functionToolToDefinition conversion (100% coverage)
- hosted-tools tests: 14 tests for isHostedTool guard and
resolveHostedTools resolution for all 4 tool types (100% coverage)
- Total: 38 new tests, all passing (1392 total)
Signed-off-by: MarioCadenas
---
packages/appkit/src/plugins/agent/agent.ts | 4 +
.../agent/tests/event-translator.test.ts | 204 ++++++++++++++++++
.../plugins/agent/tests/function-tool.test.ts | 110 ++++++++++
.../plugins/agent/tests/hosted-tools.test.ts | 131 +++++++++++
4 files changed, 449 insertions(+)
create mode 100644 packages/appkit/src/plugins/agent/tests/event-translator.test.ts
create mode 100644 packages/appkit/src/plugins/agent/tests/function-tool.test.ts
create mode 100644 packages/appkit/src/plugins/agent/tests/hosted-tools.test.ts
diff --git a/packages/appkit/src/plugins/agent/agent.ts b/packages/appkit/src/plugins/agent/agent.ts
index 43d43882..257bf6f0 100644
--- a/packages/appkit/src/plugins/agent/agent.ts
+++ b/packages/appkit/src/plugins/agent/agent.ts
@@ -549,6 +549,10 @@ export class AgentPlugin extends Plugin {
addTools: (tools: FunctionTool[]) => this.addTools(tools),
getTools: () => this.getAllToolDefinitions(),
getThreads: (userId: string) => this.threadStore.list(userId),
+ getAgents: () => ({
+ agents: Array.from(this.agents.keys()),
+ default: this.defaultAgentName,
+ }),
};
}
}
diff --git a/packages/appkit/src/plugins/agent/tests/event-translator.test.ts b/packages/appkit/src/plugins/agent/tests/event-translator.test.ts
new file mode 100644
index 00000000..eda72ebb
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/tests/event-translator.test.ts
@@ -0,0 +1,204 @@
+import { describe, expect, test } from "vitest";
+import { AgentEventTranslator } from "../event-translator";
+
+describe("AgentEventTranslator", () => {
+ test("translates message_delta to output_item.added + output_text.delta on first delta", () => {
+ const translator = new AgentEventTranslator();
+ const events = translator.translate({
+ type: "message_delta",
+ content: "Hello",
+ });
+
+ expect(events).toHaveLength(2);
+ expect(events[0].type).toBe("response.output_item.added");
+ expect(events[1].type).toBe("response.output_text.delta");
+
+ if (events[1].type === "response.output_text.delta") {
+ expect(events[1].delta).toBe("Hello");
+ }
+ });
+
+ test("subsequent message_delta only produces output_text.delta", () => {
+ const translator = new AgentEventTranslator();
+ translator.translate({ type: "message_delta", content: "Hello" });
+ const events = translator.translate({
+ type: "message_delta",
+ content: " world",
+ });
+
+ expect(events).toHaveLength(1);
+ expect(events[0].type).toBe("response.output_text.delta");
+ });
+
+ test("sequence_number is monotonically increasing", () => {
+ const translator = new AgentEventTranslator();
+ const e1 = translator.translate({ type: "message_delta", content: "a" });
+ const e2 = translator.translate({ type: "message_delta", content: "b" });
+ const e3 = translator.finalize();
+
+ const allSeqs = [...e1, ...e2, ...e3].map((e) =>
+ "sequence_number" in e ? e.sequence_number : -1,
+ );
+
+ for (let i = 1; i < allSeqs.length; i++) {
+ expect(allSeqs[i]).toBeGreaterThan(allSeqs[i - 1]);
+ }
+ });
+
+ test("translates tool_call to paired output_item.added + output_item.done", () => {
+ const translator = new AgentEventTranslator();
+ const events = translator.translate({
+ type: "tool_call",
+ callId: "call_1",
+ name: "analytics.query",
+ args: { sql: "SELECT 1" },
+ });
+
+ expect(events).toHaveLength(2);
+ expect(events[0].type).toBe("response.output_item.added");
+ expect(events[1].type).toBe("response.output_item.done");
+
+ if (events[0].type === "response.output_item.added") {
+ expect(events[0].item.type).toBe("function_call");
+ if (events[0].item.type === "function_call") {
+ expect(events[0].item.name).toBe("analytics.query");
+ expect(events[0].item.call_id).toBe("call_1");
+ }
+ }
+ });
+
+ test("translates tool_result to paired output_item events", () => {
+ const translator = new AgentEventTranslator();
+ const events = translator.translate({
+ type: "tool_result",
+ callId: "call_1",
+ result: { rows: 42 },
+ });
+
+ expect(events).toHaveLength(2);
+ expect(events[0].type).toBe("response.output_item.added");
+
+ if (events[0].type === "response.output_item.added") {
+ expect(events[0].item.type).toBe("function_call_output");
+ }
+ });
+
+ test("translates tool_result error", () => {
+ const translator = new AgentEventTranslator();
+ const events = translator.translate({
+ type: "tool_result",
+ callId: "call_1",
+ result: null,
+ error: "Query failed",
+ });
+
+ if (
+ events[0].type === "response.output_item.added" &&
+ events[0].item.type === "function_call_output"
+ ) {
+ expect(events[0].item.output).toBe("Query failed");
+ }
+ });
+
+ test("translates thinking to appkit.thinking extension event", () => {
+ const translator = new AgentEventTranslator();
+ const events = translator.translate({
+ type: "thinking",
+ content: "Let me think about this...",
+ });
+
+ expect(events).toHaveLength(1);
+ expect(events[0].type).toBe("appkit.thinking");
+ if (events[0].type === "appkit.thinking") {
+ expect(events[0].content).toBe("Let me think about this...");
+ }
+ });
+
+ test("translates metadata to appkit.metadata extension event", () => {
+ const translator = new AgentEventTranslator();
+ const events = translator.translate({
+ type: "metadata",
+ data: { threadId: "t-123" },
+ });
+
+ expect(events).toHaveLength(1);
+ expect(events[0].type).toBe("appkit.metadata");
+ if (events[0].type === "appkit.metadata") {
+ expect(events[0].data.threadId).toBe("t-123");
+ }
+ });
+
+ test("status:complete triggers finalize with response.completed", () => {
+ const translator = new AgentEventTranslator();
+ translator.translate({ type: "message_delta", content: "Hi" });
+ const events = translator.translate({ type: "status", status: "complete" });
+
+ const types = events.map((e) => e.type);
+ expect(types).toContain("response.output_item.done");
+ expect(types).toContain("response.completed");
+ });
+
+ test("status:error emits error + response.failed", () => {
+ const translator = new AgentEventTranslator();
+ const events = translator.translate({
+ type: "status",
+ status: "error",
+ error: "Something broke",
+ });
+
+ expect(events).toHaveLength(2);
+ expect(events[0].type).toBe("error");
+ expect(events[1].type).toBe("response.failed");
+
+ if (events[0].type === "error") {
+ expect(events[0].error).toBe("Something broke");
+ }
+ });
+
+ test("finalize produces response.completed", () => {
+ const translator = new AgentEventTranslator();
+ const events = translator.finalize();
+
+ expect(events.some((e) => e.type === "response.completed")).toBe(true);
+ });
+
+ test("finalize with accumulated message text produces output_item.done", () => {
+ const translator = new AgentEventTranslator();
+ translator.translate({ type: "message_delta", content: "Hello " });
+ translator.translate({ type: "message_delta", content: "world" });
+ const events = translator.finalize();
+
+ const doneEvent = events.find(
+ (e) => e.type === "response.output_item.done",
+ );
+ expect(doneEvent).toBeDefined();
+ if (
+ doneEvent?.type === "response.output_item.done" &&
+ doneEvent.item.type === "message"
+ ) {
+ expect(doneEvent.item.content[0].text).toBe("Hello world");
+ }
+ });
+
+ test("output_index increments for tool calls", () => {
+ const translator = new AgentEventTranslator();
+ const e1 = translator.translate({
+ type: "tool_call",
+ callId: "c1",
+ name: "tool1",
+ args: {},
+ });
+ const e2 = translator.translate({
+ type: "tool_result",
+ callId: "c1",
+ result: "ok",
+ });
+
+ if (
+ e1[0].type === "response.output_item.added" &&
+ e2[0].type === "response.output_item.added"
+ ) {
+ expect(e2[0].output_index).toBeGreaterThan(e1[0].output_index);
+ }
+ });
+});
diff --git a/packages/appkit/src/plugins/agent/tests/function-tool.test.ts b/packages/appkit/src/plugins/agent/tests/function-tool.test.ts
new file mode 100644
index 00000000..8e668d69
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/tests/function-tool.test.ts
@@ -0,0 +1,110 @@
+import { describe, expect, test } from "vitest";
+import {
+ functionToolToDefinition,
+ isFunctionTool,
+} from "../tools/function-tool";
+
+describe("isFunctionTool", () => {
+ test("returns true for valid FunctionTool", () => {
+ expect(
+ isFunctionTool({
+ type: "function",
+ name: "greet",
+ execute: async () => "hello",
+ }),
+ ).toBe(true);
+ });
+
+ test("returns true for minimal FunctionTool", () => {
+ expect(
+ isFunctionTool({
+ type: "function",
+ name: "x",
+ execute: () => "y",
+ }),
+ ).toBe(true);
+ });
+
+ test("returns false for null", () => {
+ expect(isFunctionTool(null)).toBe(false);
+ });
+
+ test("returns false for non-object", () => {
+ expect(isFunctionTool("function")).toBe(false);
+ });
+
+ test("returns false for wrong type", () => {
+ expect(
+ isFunctionTool({
+ type: "genie-space",
+ name: "x",
+ execute: () => "y",
+ }),
+ ).toBe(false);
+ });
+
+ test("returns false when execute is missing", () => {
+ expect(isFunctionTool({ type: "function", name: "x" })).toBe(false);
+ });
+
+ test("returns false when name is missing", () => {
+ expect(isFunctionTool({ type: "function", execute: () => "y" })).toBe(
+ false,
+ );
+ });
+});
+
+describe("functionToolToDefinition", () => {
+ test("converts a FunctionTool with all fields", () => {
+ const def = functionToolToDefinition({
+ type: "function",
+ name: "getWeather",
+ description: "Get current weather",
+ parameters: {
+ type: "object",
+ properties: { city: { type: "string" } },
+ required: ["city"],
+ },
+ execute: async () => "sunny",
+ });
+
+ expect(def.name).toBe("getWeather");
+ expect(def.description).toBe("Get current weather");
+ expect(def.parameters).toEqual({
+ type: "object",
+ properties: { city: { type: "string" } },
+ required: ["city"],
+ });
+ });
+
+ test("uses name as fallback description", () => {
+ const def = functionToolToDefinition({
+ type: "function",
+ name: "myTool",
+ execute: async () => "result",
+ });
+
+ expect(def.description).toBe("myTool");
+ });
+
+ test("uses empty object schema when parameters are null", () => {
+ const def = functionToolToDefinition({
+ type: "function",
+ name: "noParams",
+ parameters: null,
+ execute: async () => "ok",
+ });
+
+ expect(def.parameters).toEqual({ type: "object", properties: {} });
+ });
+
+ test("uses empty object schema when parameters are omitted", () => {
+ const def = functionToolToDefinition({
+ type: "function",
+ name: "noParams",
+ execute: async () => "ok",
+ });
+
+ expect(def.parameters).toEqual({ type: "object", properties: {} });
+ });
+});
diff --git a/packages/appkit/src/plugins/agent/tests/hosted-tools.test.ts b/packages/appkit/src/plugins/agent/tests/hosted-tools.test.ts
new file mode 100644
index 00000000..d5251bd4
--- /dev/null
+++ b/packages/appkit/src/plugins/agent/tests/hosted-tools.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect, test } from "vitest";
+import { isHostedTool, resolveHostedTools } from "../tools/hosted-tools";
+
+describe("isHostedTool", () => {
+ test("returns true for genie-space", () => {
+ expect(
+ isHostedTool({ type: "genie-space", genie_space: { id: "abc" } }),
+ ).toBe(true);
+ });
+
+ test("returns true for vector_search_index", () => {
+ expect(
+ isHostedTool({
+ type: "vector_search_index",
+ vector_search_index: { name: "cat.schema.idx" },
+ }),
+ ).toBe(true);
+ });
+
+ test("returns true for custom_mcp_server", () => {
+ expect(
+ isHostedTool({
+ type: "custom_mcp_server",
+ custom_mcp_server: { app_name: "my-app", app_url: "my-app-url" },
+ }),
+ ).toBe(true);
+ });
+
+ test("returns true for external_mcp_server", () => {
+ expect(
+ isHostedTool({
+ type: "external_mcp_server",
+ external_mcp_server: { connection_name: "conn1" },
+ }),
+ ).toBe(true);
+ });
+
+ test("returns false for FunctionTool", () => {
+ expect(
+ isHostedTool({ type: "function", name: "x", execute: () => "y" }),
+ ).toBe(false);
+ });
+
+ test("returns false for null", () => {
+ expect(isHostedTool(null)).toBe(false);
+ });
+
+ test("returns false for unknown type", () => {
+ expect(isHostedTool({ type: "unknown" })).toBe(false);
+ });
+
+ test("returns false for non-object", () => {
+ expect(isHostedTool(42)).toBe(false);
+ });
+});
+
+describe("resolveHostedTools", () => {
+ test("resolves genie-space to correct MCP endpoint", () => {
+ const configs = resolveHostedTools([
+ { type: "genie-space", genie_space: { id: "space123" } },
+ ]);
+
+ expect(configs).toHaveLength(1);
+ expect(configs[0].name).toBe("genie-space123");
+ expect(configs[0].path).toBe("/api/2.0/mcp/genie/space123");
+ });
+
+ test("resolves vector_search_index with 3-part name", () => {
+ const configs = resolveHostedTools([
+ {
+ type: "vector_search_index",
+ vector_search_index: { name: "catalog.schema.my_index" },
+ },
+ ]);
+
+ expect(configs).toHaveLength(1);
+ expect(configs[0].name).toBe("vs-catalog-schema-my_index");
+ expect(configs[0].path).toBe(
+ "/api/2.0/mcp/vector-search/catalog/schema/my_index",
+ );
+ });
+
+ test("throws for invalid vector_search_index name", () => {
+ expect(() =>
+ resolveHostedTools([
+ {
+ type: "vector_search_index",
+ vector_search_index: { name: "bad.name" },
+ },
+ ]),
+ ).toThrow("3-part dotted");
+ });
+
+ test("resolves custom_mcp_server", () => {
+ const configs = resolveHostedTools([
+ {
+ type: "custom_mcp_server",
+ custom_mcp_server: { app_name: "my-app", app_url: "my-app-endpoint" },
+ },
+ ]);
+
+ expect(configs[0].name).toBe("my-app");
+ expect(configs[0].path).toBe("/apps/my-app-endpoint");
+ });
+
+ test("resolves external_mcp_server", () => {
+ const configs = resolveHostedTools([
+ {
+ type: "external_mcp_server",
+ external_mcp_server: { connection_name: "conn1" },
+ },
+ ]);
+
+ expect(configs[0].name).toBe("conn1");
+ expect(configs[0].path).toBe("/api/2.0/mcp/connections/conn1");
+ });
+
+ test("resolves multiple tools preserving order", () => {
+ const configs = resolveHostedTools([
+ { type: "genie-space", genie_space: { id: "g1" } },
+ {
+ type: "external_mcp_server",
+ external_mcp_server: { connection_name: "e1" },
+ },
+ ]);
+
+ expect(configs).toHaveLength(2);
+ expect(configs[0].name).toBe("genie-g1");
+ expect(configs[1].name).toBe("e1");
+ });
+});
From 4131c2094122d1d06ae2567db8bae33dba29202e Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Wed, 8 Apr 2026 12:11:19 +0200
Subject: [PATCH 09/11] fix(appkit): bypass server.extend() autoStart guard for
/invocations
server.extend() throws when autoStart is true (by design for external
callers). The agent plugin now pushes directly into the server's
serverExtensions array for internal plugin-to-plugin coordination,
fixing the crash when using createAgent() which sets autoStart: true.
Signed-off-by: MarioCadenas
---
.../api/appkit/Function.isFunctionTool.md | 15 +++++
docs/docs/api/appkit/Function.isHostedTool.md | 15 +++++
.../docs/api/appkit/Interface.FunctionTool.md | 59 +++++++++++++++++++
docs/docs/api/appkit/TypeAlias.AgentTool.md | 7 +++
docs/docs/api/appkit/TypeAlias.HostedTool.md | 9 +++
docs/docs/api/appkit/index.md | 5 ++
docs/docs/api/appkit/typedoc-sidebar.ts | 25 ++++++++
packages/appkit/src/plugins/agent/agent.ts | 12 +++-
8 files changed, 144 insertions(+), 3 deletions(-)
create mode 100644 docs/docs/api/appkit/Function.isFunctionTool.md
create mode 100644 docs/docs/api/appkit/Function.isHostedTool.md
create mode 100644 docs/docs/api/appkit/Interface.FunctionTool.md
create mode 100644 docs/docs/api/appkit/TypeAlias.AgentTool.md
create mode 100644 docs/docs/api/appkit/TypeAlias.HostedTool.md
diff --git a/docs/docs/api/appkit/Function.isFunctionTool.md b/docs/docs/api/appkit/Function.isFunctionTool.md
new file mode 100644
index 00000000..ebd84ee4
--- /dev/null
+++ b/docs/docs/api/appkit/Function.isFunctionTool.md
@@ -0,0 +1,15 @@
+# Function: isFunctionTool()
+
+```ts
+function isFunctionTool(value: unknown): value is FunctionTool;
+```
+
+## Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `value` | `unknown` |
+
+## Returns
+
+`value is FunctionTool`
diff --git a/docs/docs/api/appkit/Function.isHostedTool.md b/docs/docs/api/appkit/Function.isHostedTool.md
new file mode 100644
index 00000000..73be7e16
--- /dev/null
+++ b/docs/docs/api/appkit/Function.isHostedTool.md
@@ -0,0 +1,15 @@
+# Function: isHostedTool()
+
+```ts
+function isHostedTool(value: unknown): value is HostedTool;
+```
+
+## Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `value` | `unknown` |
+
+## Returns
+
+`value is HostedTool`
diff --git a/docs/docs/api/appkit/Interface.FunctionTool.md b/docs/docs/api/appkit/Interface.FunctionTool.md
new file mode 100644
index 00000000..c096daca
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.FunctionTool.md
@@ -0,0 +1,59 @@
+# Interface: FunctionTool
+
+## Properties
+
+### description?
+
+```ts
+optional description: string | null;
+```
+
+***
+
+### execute()
+
+```ts
+execute: (args: Record) => string | Promise;
+```
+
+#### Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `args` | `Record`\<`string`, `unknown`\> |
+
+#### Returns
+
+`string` \| `Promise`\<`string`\>
+
+***
+
+### name
+
+```ts
+name: string;
+```
+
+***
+
+### parameters?
+
+```ts
+optional parameters: Record | null;
+```
+
+***
+
+### strict?
+
+```ts
+optional strict: boolean | null;
+```
+
+***
+
+### type
+
+```ts
+type: "function";
+```
diff --git a/docs/docs/api/appkit/TypeAlias.AgentTool.md b/docs/docs/api/appkit/TypeAlias.AgentTool.md
new file mode 100644
index 00000000..8a9da8f0
--- /dev/null
+++ b/docs/docs/api/appkit/TypeAlias.AgentTool.md
@@ -0,0 +1,7 @@
+# Type Alias: AgentTool
+
+```ts
+type AgentTool =
+ | FunctionTool
+ | HostedTool;
+```
diff --git a/docs/docs/api/appkit/TypeAlias.HostedTool.md b/docs/docs/api/appkit/TypeAlias.HostedTool.md
new file mode 100644
index 00000000..433c0ac8
--- /dev/null
+++ b/docs/docs/api/appkit/TypeAlias.HostedTool.md
@@ -0,0 +1,9 @@
+# Type Alias: HostedTool
+
+```ts
+type HostedTool =
+ | GenieTool
+ | VectorSearchIndexTool
+ | CustomMcpServerTool
+ | ExternalMcpServerTool;
+```
diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md
index 5056ba33..542150b2 100644
--- a/docs/docs/api/appkit/index.md
+++ b/docs/docs/api/appkit/index.md
@@ -39,6 +39,7 @@ plugin architecture, and React integration.
| [CacheConfig](Interface.CacheConfig.md) | Configuration for the CacheInterceptor. Controls TTL, size limits, storage backend, and probabilistic cleanup. |
| [CreateAgentConfig](Interface.CreateAgentConfig.md) | - |
| [DatabaseCredential](Interface.DatabaseCredential.md) | Database credentials with OAuth token for Postgres connection |
+| [FunctionTool](Interface.FunctionTool.md) | - |
| [GenerateDatabaseCredentialRequest](Interface.GenerateDatabaseCredentialRequest.md) | Request parameters for generating database OAuth credentials |
| [ITelemetry](Interface.ITelemetry.md) | Plugin-facing interface for OpenTelemetry instrumentation. Provides a thin abstraction over OpenTelemetry APIs for plugins. |
| [LakebasePoolConfig](Interface.LakebasePoolConfig.md) | Configuration for creating a Lakebase connection pool |
@@ -61,7 +62,9 @@ plugin architecture, and React integration.
| Type Alias | Description |
| ------ | ------ |
| [AgentEvent](TypeAlias.AgentEvent.md) | - |
+| [AgentTool](TypeAlias.AgentTool.md) | - |
| [ConfigSchema](TypeAlias.ConfigSchema.md) | Configuration schema definition for plugin config. Re-exported from the standard JSON Schema Draft 7 types. |
+| [HostedTool](TypeAlias.HostedTool.md) | - |
| [IAppRouter](TypeAlias.IAppRouter.md) | Express router type for plugin route registration |
| [PluginData](TypeAlias.PluginData.md) | Tuple of plugin class, config, and name. Created by `toPlugin()` and passed to `createApp()`. |
| [ResourcePermission](TypeAlias.ResourcePermission.md) | Union of all possible permission levels across all resource types. |
@@ -89,4 +92,6 @@ plugin architecture, and React integration.
| [getResourceRequirements](Function.getResourceRequirements.md) | Gets the resource requirements from a plugin's manifest. |
| [getUsernameWithApiLookup](Function.getUsernameWithApiLookup.md) | Resolves the PostgreSQL username for a Lakebase connection. |
| [getWorkspaceClient](Function.getWorkspaceClient.md) | Get workspace client from config or SDK default auth chain |
+| [isFunctionTool](Function.isFunctionTool.md) | - |
+| [isHostedTool](Function.isHostedTool.md) | - |
| [isSQLTypeMarker](Function.isSQLTypeMarker.md) | Type guard to check if a value is a SQL type marker |
diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts
index b7e6a80c..386d3e1e 100644
--- a/docs/docs/api/appkit/typedoc-sidebar.ts
+++ b/docs/docs/api/appkit/typedoc-sidebar.ts
@@ -127,6 +127,11 @@ const typedocSidebar: SidebarsConfig = {
id: "api/appkit/Interface.DatabaseCredential",
label: "DatabaseCredential"
},
+ {
+ type: "doc",
+ id: "api/appkit/Interface.FunctionTool",
+ label: "FunctionTool"
+ },
{
type: "doc",
id: "api/appkit/Interface.GenerateDatabaseCredentialRequest",
@@ -218,11 +223,21 @@ const typedocSidebar: SidebarsConfig = {
id: "api/appkit/TypeAlias.AgentEvent",
label: "AgentEvent"
},
+ {
+ type: "doc",
+ id: "api/appkit/TypeAlias.AgentTool",
+ label: "AgentTool"
+ },
{
type: "doc",
id: "api/appkit/TypeAlias.ConfigSchema",
label: "ConfigSchema"
},
+ {
+ type: "doc",
+ id: "api/appkit/TypeAlias.HostedTool",
+ label: "HostedTool"
+ },
{
type: "doc",
id: "api/appkit/TypeAlias.IAppRouter",
@@ -320,6 +335,16 @@ const typedocSidebar: SidebarsConfig = {
id: "api/appkit/Function.getWorkspaceClient",
label: "getWorkspaceClient"
},
+ {
+ type: "doc",
+ id: "api/appkit/Function.isFunctionTool",
+ label: "isFunctionTool"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Function.isHostedTool",
+ label: "isHostedTool"
+ },
{
type: "doc",
id: "api/appkit/Function.isSQLTypeMarker",
diff --git a/packages/appkit/src/plugins/agent/agent.ts b/packages/appkit/src/plugins/agent/agent.ts
index 257bf6f0..ee213ad0 100644
--- a/packages/appkit/src/plugins/agent/agent.ts
+++ b/packages/appkit/src/plugins/agent/agent.ts
@@ -87,12 +87,18 @@ export class AgentPlugin extends Plugin {
private mountInvocationsRoute() {
const serverPlugin = this.config.plugins?.server as
- | { extend?: (fn: (app: any) => void) => void }
+ | { serverExtensions?: Array<(app: any) => void> }
| undefined;
- if (!serverPlugin?.extend) return;
+ if (!serverPlugin) return;
- serverPlugin.extend((app: import("express").Application) => {
+ const extensions = (serverPlugin as any).serverExtensions as
+ | Array<(app: any) => void>
+ | undefined;
+
+ if (!extensions) return;
+
+ extensions.push((app: import("express").Application) => {
app.post(
"/invocations",
(req: express.Request, res: express.Response) => {
From ba0a2aec7a67e3e5c0de73f3c181a25a578f977e Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Wed, 8 Apr 2026 12:32:37 +0200
Subject: [PATCH 10/11] fix(appkit): handle undefined tool results and
oversized responses
Two issues found via runtime debugging:
1. Plugin.execute() returns undefined on failure (production-safe).
The executeTool callback now converts undefined to an error string
so the adapter always has content for the tool message.
2. Large tool results (e.g. SHOW TABLES with 14K rows) exceeded the
StreamManager 1MB event limit and blew up the model context.
Tool results are now truncated at 50K chars with a notice.
Also guard against SSE events without a type field in both frontends.
Signed-off-by: MarioCadenas
---
apps/agent-app/src/App.tsx | 1 +
apps/agent-app/vite.config.ts | 5 +++++
.../client/src/routes/agent.route.tsx | 1 +
packages/appkit/src/plugins/agent/agent.ts | 14 +++++++++++++-
4 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/apps/agent-app/src/App.tsx b/apps/agent-app/src/App.tsx
index 8fb07405..c9239f79 100644
--- a/apps/agent-app/src/App.tsx
+++ b/apps/agent-app/src/App.tsx
@@ -107,6 +107,7 @@ export default function App() {
if (!data || data === "[DONE]") continue;
try {
const event: SSEEvent = JSON.parse(data);
+ if (!event.type) continue;
setEvents((prev) => [...prev, event]);
if (event.type === "appkit.metadata" && event.data?.threadId) {
diff --git a/apps/agent-app/vite.config.ts b/apps/agent-app/vite.config.ts
index 7cd00c30..bd1cea62 100644
--- a/apps/agent-app/vite.config.ts
+++ b/apps/agent-app/vite.config.ts
@@ -13,6 +13,11 @@ export default defineConfig({
],
exclude: ["@databricks/appkit-ui", "@databricks/appkit"],
},
+ server: {
+ hmr: {
+ port: 24679,
+ },
+ },
resolve: {
dedupe: ["react", "react-dom"],
preserveSymlinks: true,
diff --git a/apps/dev-playground/client/src/routes/agent.route.tsx b/apps/dev-playground/client/src/routes/agent.route.tsx
index fa111622..48a91ba0 100644
--- a/apps/dev-playground/client/src/routes/agent.route.tsx
+++ b/apps/dev-playground/client/src/routes/agent.route.tsx
@@ -209,6 +209,7 @@ function AgentRoute() {
try {
const event: SSEEvent = JSON.parse(data);
+ if (!event.type) continue;
setEvents((prev) => [...prev, event]);
if (event.type === "appkit.metadata" && event.data?.threadId) {
diff --git a/packages/appkit/src/plugins/agent/agent.ts b/packages/appkit/src/plugins/agent/agent.ts
index ee213ad0..66736b04 100644
--- a/packages/appkit/src/plugins/agent/agent.ts
+++ b/packages/appkit/src/plugins/agent/agent.ts
@@ -325,7 +325,7 @@ export class AgentPlugin extends Plugin {
const entry = self.toolIndex.get(qualifiedName);
if (!entry) throw new Error(`Unknown tool: ${qualifiedName}`);
- return self.execute(
+ const result = await self.execute(
async (execSignal) => {
switch (entry.source) {
case "plugin": {
@@ -357,6 +357,18 @@ export class AgentPlugin extends Plugin {
},
},
);
+
+ if (result === undefined) {
+ return `Error: Tool "${qualifiedName}" execution failed`;
+ }
+
+ const MAX_TOOL_RESULT_CHARS = 50_000;
+ const serialized =
+ typeof result === "string" ? result : JSON.stringify(result);
+ if (serialized.length > MAX_TOOL_RESULT_CHARS) {
+ return `${serialized.slice(0, MAX_TOOL_RESULT_CHARS)}\n\n[Result truncated: ${serialized.length} chars exceeds ${MAX_TOOL_RESULT_CHARS} limit]`;
+ }
+ return result;
};
const requestId = randomUUID();
From 36f34760cabf2a9b15d8b2d6688fd92fd9dc7643 Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Wed, 8 Apr 2026 12:46:29 +0200
Subject: [PATCH 11/11] feat(appkit): expose agent tools and agents via
clientConfig
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace the GET /api/agent/tools and GET /api/agent/agents HTTP endpoints
with clientConfig(), which embeds the data into the HTML page at startup.
No round-trip needed — frontends read tools and agents synchronously via
getPluginClientConfig("agent") from @databricks/appkit-ui.
Signed-off-by: MarioCadenas
---
apps/agent-app/src/App.tsx | 14 ++++----
.../client/src/routes/agent.route.tsx | 17 ++++------
docs/static/appkit-ui/styles.gen.css | 28 ++++++++++++++--
packages/appkit/src/plugins/agent/agent.ts | 32 ++++---------------
.../src/plugins/agent/tests/agent.test.ts | 16 ++++++++--
5 files changed, 60 insertions(+), 47 deletions(-)
diff --git a/apps/agent-app/src/App.tsx b/apps/agent-app/src/App.tsx
index c9239f79..20d54ce6 100644
--- a/apps/agent-app/src/App.tsx
+++ b/apps/agent-app/src/App.tsx
@@ -1,3 +1,4 @@
+import { getPluginClientConfig } from "@databricks/appkit-ui/js";
import { TooltipProvider } from "@databricks/appkit-ui/react";
import { marked } from "marked";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -36,16 +37,15 @@ export default function App() {
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [threadId, setThreadId] = useState(null);
- const [toolCount, setToolCount] = useState(0);
const messagesEndRef = useRef(null);
const idRef = useRef(0);
- useEffect(() => {
- fetch("/api/agent/tools")
- .then((r) => r.json())
- .then((data) => setToolCount(data.tools?.length ?? 0))
- .catch(() => {});
- }, []);
+ const agentConfig = getPluginClientConfig<{
+ tools?: Array<{ name: string }>;
+ agents?: string[];
+ defaultAgent?: string;
+ }>("agent");
+ const toolCount = agentConfig.tools?.length ?? 0;
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages
useEffect(() => {
diff --git a/apps/dev-playground/client/src/routes/agent.route.tsx b/apps/dev-playground/client/src/routes/agent.route.tsx
index 48a91ba0..613d4d1f 100644
--- a/apps/dev-playground/client/src/routes/agent.route.tsx
+++ b/apps/dev-playground/client/src/routes/agent.route.tsx
@@ -1,3 +1,4 @@
+import { getPluginClientConfig } from "@databricks/appkit-ui/js";
import { Button } from "@databricks/appkit-ui/react";
import { createFileRoute } from "@tanstack/react-router";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -125,11 +126,16 @@ function AgentRoute() {
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [threadId, setThreadId] = useState(null);
- const [hasAutocomplete, setHasAutocomplete] = useState(false);
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
const msgIdCounter = useRef(0);
+ const agentConfig = getPluginClientConfig<{
+ agents?: string[];
+ defaultAgent?: string;
+ }>("agent");
+ const hasAutocomplete = (agentConfig.agents ?? []).includes("autocomplete");
+
const {
suggestion,
isLoading: isAutocompleting,
@@ -137,15 +143,6 @@ function AgentRoute() {
clear: clearSuggestion,
} = useAutocomplete(hasAutocomplete);
- useEffect(() => {
- fetch("/api/agent/agents")
- .then((r) => r.json())
- .then((data) => {
- setHasAutocomplete((data.agents ?? []).includes("autocomplete"));
- })
- .catch(() => {});
- }, []);
-
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
diff --git a/docs/static/appkit-ui/styles.gen.css b/docs/static/appkit-ui/styles.gen.css
index 9a9a38eb..a2192039 100644
--- a/docs/static/appkit-ui/styles.gen.css
+++ b/docs/static/appkit-ui/styles.gen.css
@@ -831,9 +831,6 @@
.max-w-\[calc\(100\%-2rem\)\] {
max-width: calc(100% - 2rem);
}
- .max-w-full {
- max-width: 100%;
- }
.max-w-max {
max-width: max-content;
}
@@ -4514,6 +4511,11 @@
width: calc(var(--spacing) * 5);
}
}
+ .\[\&_\[data-slot\=scroll-area-viewport\]\>div\]\:\!block {
+ & [data-slot=scroll-area-viewport]>div {
+ display: block !important;
+ }
+ }
.\[\&_a\]\:underline {
& a {
text-decoration-line: underline;
@@ -4637,11 +4639,26 @@
color: var(--muted-foreground);
}
}
+ .\[\&_table\]\:block {
+ & table {
+ display: block;
+ }
+ }
+ .\[\&_table\]\:max-w-full {
+ & table {
+ max-width: 100%;
+ }
+ }
.\[\&_table\]\:border-collapse {
& table {
border-collapse: collapse;
}
}
+ .\[\&_table\]\:overflow-x-auto {
+ & table {
+ overflow-x: auto;
+ }
+ }
.\[\&_table\]\:text-xs {
& table {
font-size: var(--text-xs);
@@ -4851,6 +4868,11 @@
width: 100%;
}
}
+ .\[\&\>\*\]\:min-w-0 {
+ &>* {
+ min-width: calc(var(--spacing) * 0);
+ }
+ }
.\[\&\>\*\]\:focus-visible\:relative {
&>* {
&:focus-visible {
diff --git a/packages/appkit/src/plugins/agent/agent.ts b/packages/appkit/src/plugins/agent/agent.ts
index 66736b04..7c8944c3 100644
--- a/packages/appkit/src/plugins/agent/agent.ts
+++ b/packages/appkit/src/plugins/agent/agent.ts
@@ -246,25 +246,14 @@ export class AgentPlugin extends Plugin {
path: "/threads/:threadId",
handler: async (req, res) => this._handleDeleteThread(req, res),
});
+ }
- this.route(router, {
- name: "tools",
- method: "get",
- path: "/tools",
- handler: async (req, res) => this._handleListTools(req, res),
- });
-
- this.route(router, {
- name: "agents",
- method: "get",
- path: "/agents",
- handler: async (_req, res) => {
- res.json({
- agents: Array.from(this.agents.keys()),
- default: this.defaultAgentName,
- });
- },
- });
+ clientConfig(): Record {
+ return {
+ tools: this.getAllToolDefinitions(),
+ agents: Array.from(this.agents.keys()),
+ defaultAgent: this.defaultAgentName,
+ };
}
private async _handleChat(
@@ -529,13 +518,6 @@ export class AgentPlugin extends Plugin {
res.json({ deleted: true });
}
- private async _handleListTools(
- _req: express.Request,
- res: express.Response,
- ): Promise {
- res.json({ tools: this.getAllToolDefinitions() });
- }
-
private resolveAgent(name?: string): RegisteredAgent | null {
if (name) return this.agents.get(name) ?? null;
if (this.defaultAgentName) {
diff --git a/packages/appkit/src/plugins/agent/tests/agent.test.ts b/packages/appkit/src/plugins/agent/tests/agent.test.ts
index 5388ca40..9296652d 100644
--- a/packages/appkit/src/plugins/agent/tests/agent.test.ts
+++ b/packages/appkit/src/plugins/agent/tests/agent.test.ts
@@ -114,7 +114,7 @@ describe("AgentPlugin", () => {
expect(tools).toEqual([]);
});
- test("injectRoutes registers all 6 routes", () => {
+ test("injectRoutes registers chat, cancel, and thread routes", () => {
const plugin = new AgentPlugin({ name: "agent" });
const { router, handlers } = createMockRouter();
@@ -125,7 +125,19 @@ describe("AgentPlugin", () => {
expect(handlers["GET:/threads"]).toBeDefined();
expect(handlers["GET:/threads/:threadId"]).toBeDefined();
expect(handlers["DELETE:/threads/:threadId"]).toBeDefined();
- expect(handlers["GET:/tools"]).toBeDefined();
+ });
+
+ test("clientConfig exposes tools and agents", async () => {
+ const plugin = new AgentPlugin({
+ name: "agent",
+ agents: { assistant: createMockAdapter() },
+ });
+ await plugin.setup();
+
+ const config = plugin.clientConfig();
+ expect(config.tools).toEqual([]);
+ expect(config.agents).toEqual(["assistant"]);
+ expect(config.defaultAgent).toBe("assistant");
});
test("exports().addTools adds function tools", () => {