From 48c2af3bfc085788d1ffe0c45f39cfe656afc55d Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Thu, 26 Mar 2026 12:38:14 +0100
Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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", () => {
From 3d01b672eb09b565ff617279aec11dc4a35cdae1 Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Mon, 13 Apr 2026 19:19:32 +0200
Subject: [PATCH 12/16] feat(appkit): add tools to createAgent and MCP OBO auth
- Add tools?: AgentTool[] to CreateAgentConfig, forwarded to agent() plugin
- Expose addTools(FunctionTool[]) on AgentHandle for post-setup tool addition
- MCP client callTool now accepts per-request auth headers for OBO support
- Agent plugin extracts x-forwarded-access-token from request for MCP calls
- Tool discovery at setup uses SP/token auth; tool invocations use user's OBO token
Signed-off-by: MarioCadenas
---
apps/agent-app/.env.example | 11 ++
apps/agent-app/app.yaml | 8 +
apps/agent-app/databricks.yml | 50 ++++++
apps/agent-app/package.json | 4 +-
apps/agent-app/server.ts | 13 +-
package.json | 1 +
packages/appkit/src/core/create-agent.ts | 8 +
.../src/core/tests/create-agent.test.ts | 37 +++++
packages/appkit/src/plugins/agent/agent.ts | 7 +-
.../src/plugins/agent/tools/mcp-client.ts | 19 ++-
tools/deploy-agent-app.ts | 144 ++++++++++++++++++
11 files changed, 290 insertions(+), 12 deletions(-)
create mode 100644 apps/agent-app/.env.example
create mode 100644 apps/agent-app/app.yaml
create mode 100644 apps/agent-app/databricks.yml
create mode 100644 tools/deploy-agent-app.ts
diff --git a/apps/agent-app/.env.example b/apps/agent-app/.env.example
new file mode 100644
index 00000000..c062af54
--- /dev/null
+++ b/apps/agent-app/.env.example
@@ -0,0 +1,11 @@
+# Databricks workspace (auto-injected by platform on deploy)
+DATABRICKS_HOST=https://e2-dogfood.staging.cloud.databricks.com
+
+# Agent LLM endpoint
+DATABRICKS_AGENT_ENDPOINT=databricks-claude-sonnet-4-5
+
+# Analytics plugin — SQL warehouse ID
+DATABRICKS_WAREHOUSE_ID=dd43ee29fedd958d
+
+# Files plugin — Volume path
+DATABRICKS_VOLUME_FILES=/Volumes/main/mario/mario-vol
diff --git a/apps/agent-app/app.yaml b/apps/agent-app/app.yaml
new file mode 100644
index 00000000..215b89ec
--- /dev/null
+++ b/apps/agent-app/app.yaml
@@ -0,0 +1,8 @@
+command: ['node', '--import', 'tsx', 'server.ts']
+env:
+ - name: DATABRICKS_WAREHOUSE_ID
+ valueFrom: sql-warehouse
+ - name: DATABRICKS_AGENT_ENDPOINT
+ valueFrom: serving-endpoint
+ - name: DATABRICKS_VOLUME_FILES
+ valueFrom: volume
diff --git a/apps/agent-app/databricks.yml b/apps/agent-app/databricks.yml
new file mode 100644
index 00000000..3ed6e50a
--- /dev/null
+++ b/apps/agent-app/databricks.yml
@@ -0,0 +1,50 @@
+bundle:
+ name: appkit-agent-app
+
+variables:
+ sql_warehouse_id:
+ description: SQL Warehouse ID for analytics queries
+ serving_endpoint_name:
+ description: Model Serving endpoint name for the agent LLM
+ volume_full_name:
+ description: "UC Volume full name (e.g. catalog.schema.volume_name)"
+
+resources:
+ apps:
+ agent_app:
+ name: "appkit-agent-app"
+ description: "AppKit agent with auto-discovered tools from analytics, files, and genie plugins"
+ source_code_path: ./
+
+ user_api_scopes:
+ - sql
+ - files.files
+ - dashboards.genie
+
+ resources:
+ - name: sql-warehouse
+ sql_warehouse:
+ id: ${var.sql_warehouse_id}
+ permission: CAN_USE
+
+ - name: serving-endpoint
+ serving_endpoint:
+ name: ${var.serving_endpoint_name}
+ permission: CAN_QUERY
+
+ - name: volume
+ uc_securable:
+ securable_type: VOLUME
+ securable_full_name: ${var.volume_full_name}
+ permission: WRITE_VOLUME
+
+targets:
+ dogfood:
+ default: true
+ workspace:
+ host: https://e2-dogfood.staging.cloud.databricks.com
+
+ variables:
+ sql_warehouse_id: dd43ee29fedd958d
+ serving_endpoint_name: databricks-claude-sonnet-4-5
+ volume_full_name: main.mario.mario-vol
diff --git a/apps/agent-app/package.json b/apps/agent-app/package.json
index 40f0905d..1c419214 100644
--- a/apps/agent-app/package.json
+++ b/apps/agent-app/package.json
@@ -16,7 +16,8 @@
"lucide-react": "^0.511.0",
"react": "19.2.0",
"react-dom": "19.2.0",
- "marked": "^15.0.0"
+ "marked": "^15.0.0",
+ "zod": "^3.25.67"
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.17",
@@ -28,6 +29,7 @@
"postcss": "8.5.6",
"tailwindcss": "4.1.17",
"tailwindcss-animate": "1.0.7",
+ "tw-animate-css": "1.4.0",
"tsx": "4.20.6",
"typescript": "5.9.3",
"vite": "npm:rolldown-vite@7.1.14"
diff --git a/apps/agent-app/server.ts b/apps/agent-app/server.ts
index a99a7d39..a345a9e9 100644
--- a/apps/agent-app/server.ts
+++ b/apps/agent-app/server.ts
@@ -4,18 +4,23 @@ import { WorkspaceClient } from "@databricks/sdk-experimental";
const endpointName =
process.env.DATABRICKS_AGENT_ENDPOINT ?? "databricks-claude-sonnet-4-5";
+const port = Number(process.env.DATABRICKS_APP_PORT) || 8003;
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.",
+ systemPrompt: [
+ "You are a helpful data assistant running on Databricks.",
+ "Use the available tools to query data, browse files, and help users with their analysis.",
+ "When using analytics.query, write Databricks SQL.",
+ "When results are large, summarize the key findings rather than dumping raw data.",
+ ].join(" "),
}),
- port: 8003,
+ port,
}).then((agent) => {
const tools = agent.getTools();
- console.log(`Agent running with ${tools.length} tools`);
+ console.log(`Agent running on port ${port} with ${tools.length} tools`);
console.log("Tools:", tools.map((t) => t.name).join(", "));
});
diff --git a/package.json b/package.json
index 3fb7ef1b..1fef51f1 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"generate:app-templates": "tsx tools/generate-app-templates.ts",
"check:licenses": "tsx tools/check-licenses.ts",
"build:notice": "tsx tools/build-notice.ts > NOTICE.md",
+ "deploy:agent": "pnpm pack:sdk && tsx tools/deploy-agent-app.ts",
"deploy:playground": "pnpm pack:sdk && tsx tools/playground/deploy-playground.ts",
"clean:full": "rm -rf node_modules dist coverage && pnpm -r clean:full",
"clean": "pnpm -r clean",
diff --git a/packages/appkit/src/core/create-agent.ts b/packages/appkit/src/core/create-agent.ts
index 28e5a576..bd58ff53 100644
--- a/packages/appkit/src/core/create-agent.ts
+++ b/packages/appkit/src/core/create-agent.ts
@@ -7,6 +7,8 @@ import type {
PluginData,
} from "shared";
import { agent } from "../plugins/agent";
+import type { FunctionTool } from "../plugins/agent/tools/function-tool";
+import type { AgentTool } from "../plugins/agent/types";
import { server } from "../plugins/server";
import type { TelemetryConfig } from "../telemetry";
import { createApp } from "./appkit";
@@ -28,6 +30,8 @@ export interface CreateAgentConfig {
telemetry?: TelemetryConfig;
/** Cache configuration. */
cache?: CacheConfig;
+ /** Explicit tools (FunctionTool, HostedTool) alongside auto-discovered ToolProvider tools. */
+ tools?: AgentTool[];
/** Pre-configured WorkspaceClient. */
client?: WorkspaceClient;
}
@@ -35,6 +39,8 @@ export interface CreateAgentConfig {
export interface AgentHandle {
/** Register an additional agent at runtime. */
registerAgent: (name: string, adapter: AgentAdapter) => void;
+ /** Add function tools at runtime (HostedTools must be configured at setup). */
+ addTools: (tools: FunctionTool[]) => void;
/** Get all tool definitions available to agents. */
getTools: () => AgentToolDefinition[];
/** List threads for a user. */
@@ -98,6 +104,7 @@ export async function createAgent(
agent({
agents,
defaultAgent: config.defaultAgent,
+ tools: config.tools,
}),
...(config.plugins ?? []),
server({
@@ -123,6 +130,7 @@ export async function createAgent(
return {
registerAgent: agentExports.registerAgent,
+ addTools: agentExports.addTools,
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
index 543cb82e..58df743b 100644
--- a/packages/appkit/src/core/tests/create-agent.test.ts
+++ b/packages/appkit/src/core/tests/create-agent.test.ts
@@ -203,4 +203,41 @@ describe("createAgent", () => {
handle.registerAgent("second", createMockAdapter());
expect(handle.getTools).toBeTypeOf("function");
});
+
+ test("tools config is forwarded to agent plugin", async () => {
+ const handle = await createAgent({
+ adapter: createMockAdapter(),
+ tools: [
+ {
+ type: "function" as const,
+ name: "greet",
+ description: "Say hello",
+ parameters: { type: "object", properties: {} },
+ execute: async () => "hello",
+ },
+ ],
+ });
+
+ const tools = handle.getTools();
+ expect(tools.some((t) => t.name === "greet")).toBe(true);
+ });
+
+ test("addTools is exposed on AgentHandle", async () => {
+ const handle = await createAgent({
+ adapter: createMockAdapter(),
+ });
+
+ expect(handle.addTools).toBeTypeOf("function");
+
+ handle.addTools([
+ {
+ type: "function" as const,
+ name: "farewell",
+ execute: async () => "bye",
+ },
+ ]);
+
+ const tools = handle.getTools();
+ expect(tools.some((t) => t.name === "farewell")).toBe(true);
+ });
});
diff --git a/packages/appkit/src/plugins/agent/agent.ts b/packages/appkit/src/plugins/agent/agent.ts
index 7c8944c3..7e9aeef0 100644
--- a/packages/appkit/src/plugins/agent/agent.ts
+++ b/packages/appkit/src/plugins/agent/agent.ts
@@ -335,7 +335,12 @@ export class AgentPlugin extends Plugin {
if (!self.mcpClient) {
throw new Error("MCP client not connected");
}
- return self.mcpClient.callTool(entry.mcpToolName, args);
+ const oboToken = req.headers["x-forwarded-access-token"];
+ const mcpAuth =
+ typeof oboToken === "string"
+ ? { Authorization: `Bearer ${oboToken}` }
+ : undefined;
+ return self.mcpClient.callTool(entry.mcpToolName, args, mcpAuth);
}
}
},
diff --git a/packages/appkit/src/plugins/agent/tools/mcp-client.ts b/packages/appkit/src/plugins/agent/tools/mcp-client.ts
index 8ac58788..e9e80ae7 100644
--- a/packages/appkit/src/plugins/agent/tools/mcp-client.ts
+++ b/packages/appkit/src/plugins/agent/tools/mcp-client.ts
@@ -104,7 +104,11 @@ export class AppKitMcpClient {
return defs;
}
- async callTool(qualifiedName: string, args: unknown): Promise {
+ async callTool(
+ qualifiedName: string,
+ args: unknown,
+ authHeaders?: Record,
+ ): Promise {
const parts = qualifiedName.split(".");
if (parts.length < 3 || parts[0] !== "mcp") {
throw new Error(`Invalid MCP tool name: ${qualifiedName}`);
@@ -117,10 +121,12 @@ export class AppKitMcpClient {
throw new Error(`MCP server not connected: ${serverName}`);
}
- const result = (await this.sendRpc(conn.config.path, "tools/call", {
- name: toolName,
- arguments: args,
- })) as McpToolCallResult;
+ const result = (await this.sendRpc(
+ conn.config.path,
+ "tools/call",
+ { name: toolName, arguments: args },
+ authHeaders,
+ )) as McpToolCallResult;
if (result.isError) {
const errText = result.content
@@ -145,6 +151,7 @@ export class AppKitMcpClient {
path: string,
method: string,
params?: Record,
+ authOverride?: Record,
): Promise {
if (this.closed) throw new Error("MCP client is closed");
@@ -156,7 +163,7 @@ export class AppKitMcpClient {
};
const url = `${this.workspaceHost}${path}`;
- const authHeaders = await this.authenticate();
+ const authHeaders = authOverride ?? (await this.authenticate());
const response = await fetch(url, {
method: "POST",
diff --git a/tools/deploy-agent-app.ts b/tools/deploy-agent-app.ts
new file mode 100644
index 00000000..3ac2ab3b
--- /dev/null
+++ b/tools/deploy-agent-app.ts
@@ -0,0 +1,144 @@
+import { exec as execChildProcess, spawn } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import ora from "ora";
+
+const exec = promisify(execChildProcess);
+
+const config = {
+ profile: process.env.DATABRICKS_PROFILE,
+ appName: process.env.DATABRICKS_APP_NAME,
+};
+
+const ROOT = process.cwd();
+const AGENT_APP_DIR = path.join(ROOT, "apps", "agent-app");
+const DEPLOY_DIR = path.join(ROOT, "deployable-agent");
+
+const appKitPkg = JSON.parse(
+ fs.readFileSync(
+ path.join(ROOT, "packages", "appkit", "package.json"),
+ "utf-8",
+ ),
+);
+const appKitUiPkg = JSON.parse(
+ fs.readFileSync(
+ path.join(ROOT, "packages", "appkit-ui", "package.json"),
+ "utf-8",
+ ),
+);
+const appKitTarball = path.join(
+ ROOT,
+ "packages",
+ "appkit",
+ "tmp",
+ `databricks-appkit-${appKitPkg.version}.tgz`,
+);
+const appKitUiTarball = path.join(
+ ROOT,
+ "packages",
+ "appkit-ui",
+ "tmp",
+ `databricks-appkit-ui-${appKitUiPkg.version}.tgz`,
+);
+
+async function deploy() {
+ const spinner = ora("Deploying agent-app").start();
+
+ if (!fs.existsSync(appKitTarball) || !fs.existsSync(appKitUiTarball)) {
+ spinner.fail(
+ "Tarballs not found. Run `pnpm pack:sdk` first to build them.",
+ );
+ process.exit(1);
+ }
+
+ if (fs.existsSync(DEPLOY_DIR)) {
+ const databricksState = path.join(DEPLOY_DIR, ".databricks");
+ if (fs.existsSync(databricksState)) {
+ fs.cpSync(databricksState, path.join(AGENT_APP_DIR, ".databricks"), {
+ recursive: true,
+ });
+ }
+ fs.rmSync(DEPLOY_DIR, { recursive: true });
+ }
+
+ spinner.text = "Copying agent-app to deploy folder";
+ fs.cpSync(AGENT_APP_DIR, DEPLOY_DIR, {
+ recursive: true,
+ filter: (src) =>
+ !src.includes("node_modules") && !src.includes(".databricks"),
+ });
+
+ spinner.text = "Patching package.json with tarballs";
+ const pkgPath = path.join(DEPLOY_DIR, "package.json");
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
+
+ pkg.dependencies["@databricks/appkit"] =
+ `file:./databricks-appkit-${appKitPkg.version}.tgz`;
+ pkg.dependencies["@databricks/appkit-ui"] =
+ `file:./databricks-appkit-ui-${appKitUiPkg.version}.tgz`;
+
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
+
+ spinner.text = "Copying tarballs";
+ fs.copyFileSync(
+ appKitTarball,
+ path.join(DEPLOY_DIR, `databricks-appkit-${appKitPkg.version}.tgz`),
+ );
+ fs.copyFileSync(
+ appKitUiTarball,
+ path.join(DEPLOY_DIR, `databricks-appkit-ui-${appKitUiPkg.version}.tgz`),
+ );
+
+ spinner.text = "Patching vite.config.ts (removing monorepo aliases)";
+ const viteConfigPath = path.join(DEPLOY_DIR, "vite.config.ts");
+ if (fs.existsSync(viteConfigPath)) {
+ let viteConfig = fs.readFileSync(viteConfigPath, "utf-8");
+ viteConfig = viteConfig
+ .replace(/import path from "node:path";\n?/, "")
+ .replace(/alias:\s*\{[^}]*\},?\n?/s, "")
+ .replace(/preserveSymlinks:\s*true,?\n?/, "")
+ .replace(/exclude:\s*\[[^\]]*\],?\n?/, "");
+ fs.writeFileSync(viteConfigPath, viteConfig);
+ }
+
+ process.chdir(DEPLOY_DIR);
+
+ const username = os.userInfo().username;
+ const appName =
+ config.appName || `${username.replace(/\./g, "-")}-appkit-agent`;
+ const profileArgs = config.profile ? ["-p", config.profile] : [];
+
+ spinner.info(`Deploying as "${appName}"`);
+ await execWithOutput("databricks", [
+ "apps",
+ "deploy",
+ "--skip-validation",
+ ...profileArgs,
+ ]);
+
+ spinner.succeed(`Agent app "${appName}" deployed`);
+}
+
+function execWithOutput(
+ command: string,
+ args: string[],
+): Promise<{ code: number }> {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, { stdio: "inherit" });
+ child.on("close", (code) => resolve({ code: code ?? 0 }));
+ child.on("error", reject);
+ });
+}
+
+deploy()
+ .catch((err) => {
+ console.error("Deploy failed:", err);
+ process.exit(1);
+ })
+ .finally(() => {
+ if (fs.existsSync(DEPLOY_DIR)) {
+ fs.rmSync(DEPLOY_DIR, { recursive: true });
+ }
+ });
From bb89ba98c0f8c2600d4c0718a6e736ae154e18cc Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Mon, 13 Apr 2026 19:43:57 +0200
Subject: [PATCH 13/16] fix(appkit): use WorkspaceClient auth for MCP tool
discovery
connectHostedTools now uses WorkspaceClient.config.authenticate() for
setup-time MCP auth instead of raw DATABRICKS_TOKEN. This works with
service principal auth on deployed apps. Falls back to env var if
WorkspaceClient is unavailable.
Also add error handling and logging around MCP connection failures.
Signed-off-by: MarioCadenas
---
apps/agent-app/server.ts | 9 +++-
docs/docs/api/appkit/Interface.AgentHandle.md | 20 ++++++++
.../api/appkit/Interface.CreateAgentConfig.md | 10 ++++
packages/appkit/src/plugins/agent/agent.ts | 47 ++++++++++++++-----
4 files changed, 74 insertions(+), 12 deletions(-)
diff --git a/apps/agent-app/server.ts b/apps/agent-app/server.ts
index a345a9e9..ea2312fe 100644
--- a/apps/agent-app/server.ts
+++ b/apps/agent-app/server.ts
@@ -8,14 +8,21 @@ const port = Number(process.env.DATABRICKS_APP_PORT) || 8003;
createAgent({
plugins: [analytics(), files()],
+ tools: [
+ {
+ type: "external_mcp_server",
+ external_mcp_server: { connection_name: "confluence-mcp-dogfood" },
+ },
+ ],
adapter: DatabricksAdapter.fromServingEndpoint({
workspaceClient: new WorkspaceClient({}),
endpointName,
systemPrompt: [
"You are a helpful data assistant running on Databricks.",
- "Use the available tools to query data, browse files, and help users with their analysis.",
+ "Use the available tools to query data, browse files, search Confluence, and help users with their analysis.",
"When using analytics.query, write Databricks SQL.",
"When results are large, summarize the key findings rather than dumping raw data.",
+ "You have access to Confluence — use it when users ask about documentation or internal knowledge.",
].join(" "),
}),
port,
diff --git a/docs/docs/api/appkit/Interface.AgentHandle.md b/docs/docs/api/appkit/Interface.AgentHandle.md
index 1b5a09c2..d630567d 100644
--- a/docs/docs/api/appkit/Interface.AgentHandle.md
+++ b/docs/docs/api/appkit/Interface.AgentHandle.md
@@ -2,6 +2,26 @@
## Properties
+### addTools()
+
+```ts
+addTools: (tools: FunctionTool[]) => void;
+```
+
+Add function tools at runtime (HostedTools must be configured at setup).
+
+#### Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `tools` | [`FunctionTool`](Interface.FunctionTool.md)[] |
+
+#### Returns
+
+`void`
+
+***
+
### getThreads()
```ts
diff --git a/docs/docs/api/appkit/Interface.CreateAgentConfig.md b/docs/docs/api/appkit/Interface.CreateAgentConfig.md
index 5d83b9e1..67b6c2a2 100644
--- a/docs/docs/api/appkit/Interface.CreateAgentConfig.md
+++ b/docs/docs/api/appkit/Interface.CreateAgentConfig.md
@@ -93,3 +93,13 @@ optional telemetry: TelemetryConfig;
```
Telemetry configuration.
+
+***
+
+### tools?
+
+```ts
+optional tools: AgentTool[];
+```
+
+Explicit tools (FunctionTool, HostedTool) alongside auto-discovered ToolProvider tools.
diff --git a/packages/appkit/src/plugins/agent/agent.ts b/packages/appkit/src/plugins/agent/agent.ts
index 7e9aeef0..99114596 100644
--- a/packages/appkit/src/plugins/agent/agent.ts
+++ b/packages/appkit/src/plugins/agent/agent.ts
@@ -161,26 +161,45 @@ export class AgentPlugin extends Plugin {
private async connectHostedTools(
hostedTools: import("./tools/hosted-tools").HostedTool[],
) {
- const host = process.env.DATABRICKS_HOST;
+ let host: string | undefined;
+ let authenticate: () => Promise>;
+
+ try {
+ const { getWorkspaceClient } = await import("../../context");
+ const wsClient = getWorkspaceClient();
+ await wsClient.config.ensureResolved();
+ host = wsClient.config.host;
+ authenticate = async (): Promise> => {
+ const headers = new Headers();
+ await wsClient.config.authenticate(headers);
+ return Object.fromEntries(headers.entries());
+ };
+ } catch {
+ host = process.env.DATABRICKS_HOST;
+ authenticate = async (): Promise> => {
+ const token = process.env.DATABRICKS_TOKEN;
+ if (token) return { Authorization: `Bearer ${token}` };
+ return {};
+ };
+ }
+
if (!host) {
logger.warn(
- "DATABRICKS_HOST not set — skipping %d hosted tools",
+ "No Databricks host available — 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 {};
- },
- );
+ this.mcpClient = new AppKitMcpClient(host, authenticate);
const endpoints = resolveHostedTools(hostedTools);
- await this.mcpClient.connectAll(endpoints);
+ try {
+ await this.mcpClient.connectAll(endpoints);
+ } catch (error) {
+ logger.error("Failed to connect hosted tools: %O", error);
+ return;
+ }
for (const def of this.mcpClient.getAllToolDefinitions()) {
this.toolIndex.set(def.name, {
@@ -189,6 +208,12 @@ export class AgentPlugin extends Plugin {
def,
});
}
+
+ logger.info(
+ "Connected %d MCP tools from %d hosted tool(s)",
+ this.mcpClient.getAllToolDefinitions().length,
+ hostedTools.length,
+ );
}
private addFunctionToolToIndex(ft: FunctionTool) {
From 9762ddbf1939e7a90c757295bd349d8876bcb02a Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Tue, 14 Apr 2026 18:39:09 +0200
Subject: [PATCH 14/16] fix(appkit): address P0 review findings
1. AgentEventTranslator: add finalized guard to prevent duplicate
response.completed events when adapter emits status:complete
2. LangChain adapter: accumulate tool_call_chunks before JSON.parse
instead of parsing partial streaming fragments
3. Agent-app: remove dangerouslySetInnerHTML with unsanitized marked
output, use safe JSX text rendering instead
4. /invocations: preserve full conversation history from input array
by populating thread with all messages before streaming. Extract
shared _streamChat method used by both handlers.
5. Server plugin: add public addExtension() method for plugin-to-plugin
route registration. Agent plugin uses it instead of accessing
private serverExtensions field.
6. MCP client: add 30s AbortSignal.timeout to all fetch calls to
prevent indefinite hangs on unresponsive MCP servers.
Signed-off-by: MarioCadenas
---
apps/agent-app/server.ts | 49 ++++++-
apps/agent-app/src/App.tsx | 34 ++---
packages/appkit/src/agents/databricks.ts | 2 +-
packages/appkit/src/agents/langchain.ts | 40 ++++-
packages/appkit/src/plugins/agent/agent.ts | 136 +++++++++++++----
.../src/plugins/agent/event-translator.ts | 4 +
.../src/plugins/agent/tests/agent.test.ts | 72 ++++++++-
.../plugins/agent/tests/hosted-tools.test.ts | 8 +-
.../src/plugins/agent/tools/hosted-tools.ts | 11 +-
.../src/plugins/agent/tools/mcp-client.ts | 138 +++++++++++++-----
packages/appkit/src/plugins/server/index.ts | 10 ++
11 files changed, 397 insertions(+), 107 deletions(-)
diff --git a/apps/agent-app/server.ts b/apps/agent-app/server.ts
index ea2312fe..387cbeaf 100644
--- a/apps/agent-app/server.ts
+++ b/apps/agent-app/server.ts
@@ -10,8 +10,43 @@ createAgent({
plugins: [analytics(), files()],
tools: [
{
- type: "external_mcp_server",
- external_mcp_server: { connection_name: "confluence-mcp-dogfood" },
+ type: "function",
+ name: "get_weather",
+ description: "Get the current weather for a city",
+ parameters: {
+ type: "object",
+ properties: {
+ city: { type: "string", description: "City name" },
+ },
+ required: ["city"],
+ },
+ execute: async ({ city }) => {
+ return `The weather in ${city} is sunny, 22°C`;
+ },
+ },
+ {
+ type: "custom_mcp_server",
+ custom_mcp_server: {
+ app_name: "mario-mcp-hello",
+ app_url:
+ "https://mario-mcp-hello-6051921418418893.staging.aws.databricksapps.com/mcp",
+ },
+ },
+ {
+ type: "custom_mcp_server",
+ custom_mcp_server: {
+ app_name: "vector-search",
+ app_url:
+ "https://e2-dogfood.staging.cloud.databricks.com/api/2.0/mcp/vector-search/main/default",
+ },
+ },
+ {
+ type: "custom_mcp_server",
+ custom_mcp_server: {
+ app_name: "uc-greet",
+ app_url:
+ "https://e2-dogfood.staging.cloud.databricks.com/api/2.0/mcp/functions/main/mario/greet",
+ },
},
],
adapter: DatabricksAdapter.fromServingEndpoint({
@@ -19,15 +54,15 @@ createAgent({
endpointName,
systemPrompt: [
"You are a helpful data assistant running on Databricks.",
- "Use the available tools to query data, browse files, search Confluence, and help users with their analysis.",
+ "Use the available tools to query data, browse files, and help users with their analysis.",
"When using analytics.query, write Databricks SQL.",
"When results are large, summarize the key findings rather than dumping raw data.",
- "You have access to Confluence — use it when users ask about documentation or internal knowledge.",
+ "You also have access to additional tools from MCP servers — use them when relevant.",
].join(" "),
}),
port,
}).then((agent) => {
- const tools = agent.getTools();
- console.log(`Agent running on port ${port} with ${tools.length} tools`);
- console.log("Tools:", tools.map((t) => t.name).join(", "));
+ console.log(
+ `Agent running on port ${port} with ${agent.getTools().length} tools`,
+ );
});
diff --git a/apps/agent-app/src/App.tsx b/apps/agent-app/src/App.tsx
index 20d54ce6..5c54997a 100644
--- a/apps/agent-app/src/App.tsx
+++ b/apps/agent-app/src/App.tsx
@@ -1,6 +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";
import "./App.css";
import { ThemeSelector } from "./components/theme-selector";
@@ -40,12 +38,17 @@ export default function App() {
const messagesEndRef = useRef(null);
const idRef = useRef(0);
- const agentConfig = getPluginClientConfig<{
- tools?: Array<{ name: string }>;
- agents?: string[];
- defaultAgent?: string;
- }>("agent");
- const toolCount = agentConfig.tools?.length ?? 0;
+ const [toolCount, setToolCount] = useState(0);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ fetch("/api/agent/info")
+ .then((r) => r.json())
+ .then((data) => setToolCount(data.toolCount ?? 0))
+ .catch(() => {});
+ }, 500);
+ return () => clearTimeout(timer);
+ }, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages
useEffect(() => {
@@ -188,18 +191,9 @@ export default function App() {
key={msg.id}
className={`message-row ${msg.role === "user" ? "user" : "assistant"}`}
>
-
+
))}
diff --git a/packages/appkit/src/agents/databricks.ts b/packages/appkit/src/agents/databricks.ts
index 79ab0ed1..02d701f4 100644
--- a/packages/appkit/src/agents/databricks.ts
+++ b/packages/appkit/src/agents/databricks.ts
@@ -326,7 +326,7 @@ export class DatabricksAdapter implements AgentAdapter {
).map((tc) => ({
id: tc.id,
type: "function" as const,
- function: { name: tc.name, arguments: tc.arguments },
+ function: { name: tc.name, arguments: tc.arguments || "{}" },
}));
return { text: fullText, toolCalls };
diff --git a/packages/appkit/src/agents/langchain.ts b/packages/appkit/src/agents/langchain.ts
index a1eb5f75..a2534034 100644
--- a/packages/appkit/src/agents/langchain.ts
+++ b/packages/appkit/src/agents/langchain.ts
@@ -67,6 +67,11 @@ export class LangChainAdapter implements AgentAdapter {
},
);
+ const toolCallAccumulator = new Map<
+ number,
+ { id: string; name: string; arguments: string }
+ >();
+
for await (const event of stream) {
if (context.signal?.aborted) break;
@@ -78,19 +83,42 @@ export class LangChainAdapter implements AgentAdapter {
}
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,
+ const idx = tc.index ?? 0;
+ const existing = toolCallAccumulator.get(idx);
+ if (existing) {
+ if (tc.args) existing.arguments += tc.args;
+ } else if (tc.name) {
+ toolCallAccumulator.set(idx, {
+ id: tc.id ?? tc.name,
name: tc.name,
- args: tc.args ? JSON.parse(tc.args) : {},
- };
+ arguments: tc.args ?? "",
+ });
}
}
}
break;
}
+ case "on_tool_start": {
+ const accumulated = Array.from(toolCallAccumulator.values());
+ for (const tc of accumulated) {
+ let args: unknown;
+ try {
+ args = JSON.parse(tc.arguments || "{}");
+ } catch {
+ args = {};
+ }
+ yield {
+ type: "tool_call" as const,
+ callId: tc.id,
+ name: tc.name,
+ args,
+ };
+ }
+ toolCallAccumulator.clear();
+ break;
+ }
+
case "on_tool_end": {
const output = event.data?.output;
yield {
diff --git a/packages/appkit/src/plugins/agent/agent.ts b/packages/appkit/src/plugins/agent/agent.ts
index 99114596..f7d1ddef 100644
--- a/packages/appkit/src/plugins/agent/agent.ts
+++ b/packages/appkit/src/plugins/agent/agent.ts
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import type express from "express";
+import pc from "picocolors";
import type {
AgentAdapter,
AgentToolDefinition,
@@ -87,18 +88,12 @@ export class AgentPlugin extends Plugin {
private mountInvocationsRoute() {
const serverPlugin = this.config.plugins?.server as
- | { serverExtensions?: Array<(app: any) => void> }
+ | { addExtension?: (fn: (app: any) => void) => void }
| undefined;
- if (!serverPlugin) return;
+ if (!serverPlugin?.addExtension) return;
- const extensions = (serverPlugin as any).serverExtensions as
- | Array<(app: any) => void>
- | undefined;
-
- if (!extensions) return;
-
- extensions.push((app: import("express").Application) => {
+ serverPlugin.addExtension((app: import("express").Application) => {
app.post(
"/invocations",
(req: express.Request, res: express.Response) => {
@@ -155,7 +150,53 @@ export class AgentPlugin extends Plugin {
}
}
- logger.info("Total agent tools: %d", this.toolIndex.size);
+ this.printTools();
+ }
+
+ private printTools() {
+ const entries = Array.from(this.toolIndex.values());
+ if (entries.length === 0) return;
+
+ const SOURCE_COLORS: Record string> = {
+ plugin: pc.blue,
+ function: pc.yellow,
+ mcp: pc.magenta,
+ };
+
+ const rows = entries
+ .map((e) => ({
+ source: e.source,
+ name: e.def.name,
+ description: e.def.description.slice(0, 60),
+ }))
+ .sort(
+ (a, b) =>
+ a.source.localeCompare(b.source) || a.name.localeCompare(b.name),
+ );
+
+ const maxSourceLen = Math.max(...rows.map((r) => r.source.length));
+ const maxNameLen = Math.min(
+ 40,
+ Math.max(...rows.map((r) => r.name.length)),
+ );
+ const separator = pc.dim("─".repeat(60));
+
+ console.log("");
+ console.log(` ${pc.bold("Agent Tools")} ${pc.dim(`(${rows.length})`)}`);
+ console.log(` ${separator}`);
+
+ for (const { source, name, description } of rows) {
+ const colorize = SOURCE_COLORS[source] ?? pc.white;
+ const sourceStr = colorize(pc.bold(source.padEnd(maxSourceLen)));
+ const nameStr =
+ name.length > maxNameLen
+ ? `${name.slice(0, maxNameLen - 1)}…`
+ : name.padEnd(maxNameLen);
+ console.log(` ${sourceStr} ${nameStr} ${pc.dim(description)}`);
+ }
+
+ console.log(` ${separator}`);
+ console.log("");
}
private async connectHostedTools(
@@ -194,12 +235,7 @@ export class AgentPlugin extends Plugin {
this.mcpClient = new AppKitMcpClient(host, authenticate);
const endpoints = resolveHostedTools(hostedTools);
- try {
- await this.mcpClient.connectAll(endpoints);
- } catch (error) {
- logger.error("Failed to connect hosted tools: %O", error);
- return;
- }
+ await this.mcpClient.connectAll(endpoints);
for (const def of this.mcpClient.getAllToolDefinitions()) {
this.toolIndex.set(def.name, {
@@ -271,6 +307,20 @@ export class AgentPlugin extends Plugin {
path: "/threads/:threadId",
handler: async (req, res) => this._handleDeleteThread(req, res),
});
+
+ this.route(router, {
+ name: "info",
+ method: "get",
+ path: "/info",
+ handler: async (_req, res) => {
+ res.json({
+ toolCount: this.toolIndex.size,
+ tools: this.getAllToolDefinitions(),
+ agents: Array.from(this.agents.keys()),
+ defaultAgent: this.defaultAgentName,
+ });
+ },
+ });
}
clientConfig(): Record {
@@ -327,6 +377,16 @@ export class AgentPlugin extends Plugin {
};
await this.threadStore.addMessage(thread.id, userId, userMessage);
+ return this._streamChat(req, res, resolvedAgent, thread, userId);
+ }
+
+ private async _streamChat(
+ req: express.Request,
+ res: express.Response,
+ resolvedAgent: RegisteredAgent,
+ thread: import("shared").Thread,
+ userId: string,
+ ): Promise {
const tools = this.getAllToolDefinitions();
const abortController = new AbortController();
const signal = abortController.signal;
@@ -343,9 +403,7 @@ export class AgentPlugin extends Plugin {
async (execSignal) => {
switch (entry.source) {
case "plugin": {
- const target = entry.def.annotations?.requiresUserContext
- ? (entry.plugin as any).asUser(req)
- : entry.plugin;
+ const target = (entry.plugin as any).asUser(req);
return (target as ToolProvider).executeAgentTool(
entry.localName,
args,
@@ -478,22 +536,42 @@ export class AgentPlugin extends Plugin {
}
const { input } = parsed.data;
+ const resolvedAgent = this.resolveAgent();
+ if (!resolvedAgent) {
+ res.status(400).json({ error: "No agent registered" });
+ return;
+ }
+
+ const userId = this.resolveUserId(req);
+ const thread = await this.threadStore.create(userId);
- let userMessage: string;
if (typeof input === "string") {
- userMessage = input;
+ const msg: Message = {
+ id: randomUUID(),
+ role: "user",
+ content: input,
+ createdAt: new Date(),
+ };
+ await this.threadStore.addMessage(thread.id, userId, msg);
} else {
- 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;
+ for (const item of input) {
+ const role = item.role ?? "user";
+ const content =
+ typeof item.content === "string"
+ ? item.content
+ : JSON.stringify(item.content ?? "");
+ if (!content) continue;
+ const msg: Message = {
+ id: randomUUID(),
+ role: role as Message["role"],
+ content,
+ createdAt: new Date(),
+ };
+ await this.threadStore.addMessage(thread.id, userId, msg);
}
- userMessage = content;
}
- req.body = { message: userMessage };
- return this._handleChat(req, res);
+ return this._streamChat(req, res, resolvedAgent, thread, userId);
}
private async _handleCancel(
diff --git a/packages/appkit/src/plugins/agent/event-translator.ts b/packages/appkit/src/plugins/agent/event-translator.ts
index 9fbbbe5f..314f8066 100644
--- a/packages/appkit/src/plugins/agent/event-translator.ts
+++ b/packages/appkit/src/plugins/agent/event-translator.ts
@@ -18,6 +18,7 @@ export class AgentEventTranslator {
private outputIndex = 0;
private messageId: string | null = null;
private messageText = "";
+ private finalized = false;
translate(event: AgentEvent): ResponseStreamEvent[] {
switch (event.type) {
@@ -51,6 +52,9 @@ export class AgentEventTranslator {
}
finalize(): ResponseStreamEvent[] {
+ if (this.finalized) return [];
+ this.finalized = true;
+
const events: ResponseStreamEvent[] = [];
if (this.messageId) {
diff --git a/packages/appkit/src/plugins/agent/tests/agent.test.ts b/packages/appkit/src/plugins/agent/tests/agent.test.ts
index 9296652d..89e11fc9 100644
--- a/packages/appkit/src/plugins/agent/tests/agent.test.ts
+++ b/packages/appkit/src/plugins/agent/tests/agent.test.ts
@@ -1,7 +1,14 @@
-import { createMockRouter, setupDatabricksEnv } from "@tools/test-helpers";
+import {
+ createMockRequest,
+ createMockResponse,
+ createMockRouter,
+ setupDatabricksEnv,
+} from "@tools/test-helpers";
import type {
AgentAdapter,
AgentEvent,
+ AgentInput,
+ AgentRunContext,
AgentToolDefinition,
ToolProvider,
} from "shared";
@@ -20,6 +27,18 @@ vi.mock("../../../cache", () => ({
},
}));
+vi.mock("../../../context", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getCurrentUserId: vi.fn(() => "test-user"),
+ getExecutionContext: vi.fn(() => ({
+ userId: "test-user",
+ isUserContext: false,
+ })),
+ };
+});
+
vi.mock("../../../telemetry", () => ({
TelemetryManager: {
getProvider: vi.fn(() => ({
@@ -157,4 +176,55 @@ describe("AgentPlugin", () => {
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe("myTool");
});
+
+ test("executeTool always calls asUser(req) for plugin tools, even without requiresUserContext", async () => {
+ const mockProvider = createMockToolProvider([
+ {
+ name: "action",
+ description: "An action without requiresUserContext",
+ parameters: { type: "object", properties: {} },
+ },
+ ]);
+
+ function createToolCallingAdapter(): AgentAdapter {
+ return {
+ async *run(
+ input: AgentInput,
+ context: AgentRunContext,
+ ): AsyncGenerator {
+ await context.executeTool("testplugin.action", {});
+ yield { type: "message_delta", content: "done" };
+ },
+ };
+ }
+
+ const plugin = new AgentPlugin({
+ name: "agent",
+ agents: { assistant: createToolCallingAdapter() },
+ plugins: { testplugin: mockProvider },
+ });
+ await plugin.setup();
+
+ const { router, getHandler } = createMockRouter();
+ plugin.injectRoutes(router);
+ const handler = getHandler("POST", "/chat");
+
+ const req = createMockRequest({
+ body: { message: "hi" },
+ headers: {
+ "x-forwarded-user": "test-user",
+ "x-forwarded-access-token": "test-token",
+ },
+ });
+ const res = createMockResponse();
+
+ await handler(req, res);
+
+ expect(mockProvider.asUser).toHaveBeenCalledWith(req);
+ expect(mockProvider.executeAgentTool).toHaveBeenCalledWith(
+ "action",
+ {},
+ expect.anything(),
+ );
+ });
});
diff --git a/packages/appkit/src/plugins/agent/tests/hosted-tools.test.ts b/packages/appkit/src/plugins/agent/tests/hosted-tools.test.ts
index d5251bd4..d62b266b 100644
--- a/packages/appkit/src/plugins/agent/tests/hosted-tools.test.ts
+++ b/packages/appkit/src/plugins/agent/tests/hosted-tools.test.ts
@@ -62,7 +62,7 @@ describe("resolveHostedTools", () => {
expect(configs).toHaveLength(1);
expect(configs[0].name).toBe("genie-space123");
- expect(configs[0].path).toBe("/api/2.0/mcp/genie/space123");
+ expect(configs[0].url).toBe("/api/2.0/mcp/genie/space123");
});
test("resolves vector_search_index with 3-part name", () => {
@@ -75,7 +75,7 @@ describe("resolveHostedTools", () => {
expect(configs).toHaveLength(1);
expect(configs[0].name).toBe("vs-catalog-schema-my_index");
- expect(configs[0].path).toBe(
+ expect(configs[0].url).toBe(
"/api/2.0/mcp/vector-search/catalog/schema/my_index",
);
});
@@ -100,7 +100,7 @@ describe("resolveHostedTools", () => {
]);
expect(configs[0].name).toBe("my-app");
- expect(configs[0].path).toBe("/apps/my-app-endpoint");
+ expect(configs[0].url).toBe("my-app-endpoint");
});
test("resolves external_mcp_server", () => {
@@ -112,7 +112,7 @@ describe("resolveHostedTools", () => {
]);
expect(configs[0].name).toBe("conn1");
- expect(configs[0].path).toBe("/api/2.0/mcp/connections/conn1");
+ expect(configs[0].url).toBe("/api/2.0/mcp/external/conn1");
});
test("resolves multiple tools preserving order", () => {
diff --git a/packages/appkit/src/plugins/agent/tools/hosted-tools.ts b/packages/appkit/src/plugins/agent/tools/hosted-tools.ts
index 23942ae7..33d6f6da 100644
--- a/packages/appkit/src/plugins/agent/tools/hosted-tools.ts
+++ b/packages/appkit/src/plugins/agent/tools/hosted-tools.ts
@@ -39,7 +39,8 @@ export function isHostedTool(value: unknown): value is HostedTool {
export interface McpEndpointConfig {
name: string;
- path: string;
+ /** Absolute URL or path relative to workspace host */
+ url: string;
}
/**
@@ -51,7 +52,7 @@ function resolveHostedTool(tool: HostedTool): McpEndpointConfig {
case "genie-space":
return {
name: `genie-${tool.genie_space.id}`,
- path: `/api/2.0/mcp/genie/${tool.genie_space.id}`,
+ url: `/api/2.0/mcp/genie/${tool.genie_space.id}`,
};
case "vector_search_index": {
const parts = tool.vector_search_index.name.split(".");
@@ -62,18 +63,18 @@ function resolveHostedTool(tool: HostedTool): McpEndpointConfig {
}
return {
name: `vs-${parts.join("-")}`,
- path: `/api/2.0/mcp/vector-search/${parts[0]}/${parts[1]}/${parts[2]}`,
+ url: `/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}`,
+ url: 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}`,
+ url: `/api/2.0/mcp/external/${tool.external_mcp_server.connection_name}`,
};
}
}
diff --git a/packages/appkit/src/plugins/agent/tools/mcp-client.ts b/packages/appkit/src/plugins/agent/tools/mcp-client.ts
index e9e80ae7..bd96d348 100644
--- a/packages/appkit/src/plugins/agent/tools/mcp-client.ts
+++ b/packages/appkit/src/plugins/agent/tools/mcp-client.ts
@@ -31,6 +31,7 @@ interface McpToolCallResult {
interface McpServerConnection {
config: McpEndpointConfig;
+ resolvedUrl: string;
tools: Map;
}
@@ -43,6 +44,7 @@ interface McpServerConnection {
*/
export class AppKitMcpClient {
private connections = new Map();
+ private sessionIds = new Map();
private requestId = 0;
private closed = false;
@@ -52,33 +54,66 @@ export class AppKitMcpClient {
) {}
async connectAll(endpoints: McpEndpointConfig[]): Promise {
- await Promise.all(endpoints.map((ep) => this.connect(ep)));
+ const results = await Promise.allSettled(
+ endpoints.map((ep) => this.connect(ep)),
+ );
+ for (let i = 0; i < results.length; i++) {
+ if (results[i].status === "rejected") {
+ logger.error(
+ "Failed to connect MCP server %s: %O",
+ endpoints[i].name,
+ (results[i] as PromiseRejectedResult).reason,
+ );
+ }
+ }
+ }
+
+ private resolveUrl(endpoint: McpEndpointConfig): string {
+ if (
+ endpoint.url.startsWith("http://") ||
+ endpoint.url.startsWith("https://")
+ ) {
+ return endpoint.url;
+ }
+ return `${this.workspaceHost}${endpoint.url}`;
}
async connect(endpoint: McpEndpointConfig): Promise {
- logger.info(
- "Connecting to MCP server: %s at %s",
- endpoint.name,
- endpoint.path,
- );
+ const url = this.resolveUrl(endpoint);
+ logger.info("Connecting to MCP server: %s at %s", endpoint.name, url);
- await this.sendRpc(endpoint.path, "initialize", {
+ const initResponse = await this.sendRpc(url, "initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "appkit-agent", version: "0.1.0" },
});
- await this.sendNotification(endpoint.path, "notifications/initialized");
+ if (initResponse.sessionId) {
+ this.sessionIds.set(endpoint.name, initResponse.sessionId);
+ }
+ const sessionId = this.sessionIds.get(endpoint.name);
+
+ await this.sendNotification(url, "notifications/initialized", sessionId);
- const result = await this.sendRpc(endpoint.path, "tools/list", {});
- const toolList = (result as { tools?: McpToolSchema[] })?.tools ?? [];
+ const listResponse = await this.sendRpc(
+ url,
+ "tools/list",
+ {},
+ { sessionId },
+ );
+ const toolList =
+ (listResponse.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 });
+ this.connections.set(endpoint.name, {
+ config: endpoint,
+ resolvedUrl: url,
+ tools,
+ });
logger.info(
"Connected to MCP server %s: %d tools available",
endpoint.name,
@@ -121,12 +156,14 @@ export class AppKitMcpClient {
throw new Error(`MCP server not connected: ${serverName}`);
}
- const result = (await this.sendRpc(
- conn.config.path,
+ const sessionId = this.sessionIds.get(serverName);
+ const rpcResult = await this.sendRpc(
+ conn.resolvedUrl,
"tools/call",
{ name: toolName, arguments: args },
- authHeaders,
- )) as McpToolCallResult;
+ { authOverride: authHeaders, sessionId },
+ );
+ const result = rpcResult.result as McpToolCallResult;
if (result.isError) {
const errText = result.content
@@ -148,11 +185,14 @@ export class AppKitMcpClient {
}
private async sendRpc(
- path: string,
+ url: string,
method: string,
params?: Record,
- authOverride?: Record,
- ): Promise {
+ options?: {
+ authOverride?: Record;
+ sessionId?: string;
+ },
+ ): Promise<{ result: unknown; sessionId?: string }> {
if (this.closed) throw new Error("MCP client is closed");
const request: JsonRpcRequest = {
@@ -162,17 +202,21 @@ export class AppKitMcpClient {
...(params && { params }),
};
- const url = `${this.workspaceHost}${path}`;
- const authHeaders = authOverride ?? (await this.authenticate());
+ const authHeaders = options?.authOverride ?? (await this.authenticate());
+ const headers: Record = {
+ "Content-Type": "application/json",
+ Accept: "application/json, text/event-stream",
+ ...authHeaders,
+ };
+ if (options?.sessionId) {
+ headers["Mcp-Session-Id"] = options.sessionId;
+ }
const response = await fetch(url, {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- Accept: "application/json",
- ...authHeaders,
- },
+ headers,
body: JSON.stringify(request),
+ signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
@@ -181,28 +225,54 @@ export class AppKitMcpClient {
);
}
- const json = (await response.json()) as JsonRpcResponse;
+ const contentType = response.headers.get("content-type") ?? "";
+ let json: JsonRpcResponse;
+
+ if (contentType.includes("text/event-stream")) {
+ const text = await response.text();
+ const lastData = text
+ .split("\n")
+ .filter((line) => line.startsWith("data: "))
+ .map((line) => line.slice(6))
+ .pop();
+ if (!lastData) {
+ throw new Error(`MCP SSE response for ${method} contained no data`);
+ }
+ json = JSON.parse(lastData) as JsonRpcResponse;
+ } else {
+ json = (await response.json()) as JsonRpcResponse;
+ }
+
if (json.error) {
throw new Error(`MCP error (${json.error.code}): ${json.error.message}`);
}
- return json.result;
+ const sid = response.headers.get("mcp-session-id") ?? undefined;
+ return { result: json.result, sessionId: sid };
}
- private async sendNotification(path: string, method: string): Promise {
+ private async sendNotification(
+ url: string,
+ method: string,
+ sessionId?: string,
+ ): Promise {
if (this.closed) return;
- const url = `${this.workspaceHost}${path}`;
const authHeaders = await this.authenticate();
+ const headers: Record = {
+ "Content-Type": "application/json",
+ Accept: "application/json, text/event-stream",
+ ...authHeaders,
+ };
+ if (sessionId) {
+ headers["Mcp-Session-Id"] = sessionId;
+ }
await fetch(url, {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- Accept: "application/json",
- ...authHeaders,
- },
+ headers,
body: JSON.stringify({ jsonrpc: "2.0", method }),
+ signal: AbortSignal.timeout(30_000),
});
}
}
diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts
index e7b9b31a..75d3e1d0 100644
--- a/packages/appkit/src/plugins/server/index.ts
+++ b/packages/appkit/src/plugins/server/index.ts
@@ -179,6 +179,16 @@ export class ServerPlugin extends Plugin {
return this;
}
+ /**
+ * Register a server extension from another plugin during setup.
+ * Unlike extend(), this does not guard on autoStart — it's designed
+ * for internal plugin-to-plugin coordination where extensions are
+ * registered before the server starts listening.
+ */
+ addExtension(fn: (app: express.Application) => void) {
+ this.serverExtensions.push(fn);
+ }
+
/**
* Setup the routes with the plugins.
*
From 446f7f48557194598df82b11fc595755d6dd51d6 Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Thu, 16 Apr 2026 10:58:47 +0200
Subject: [PATCH 15/16] fix(appkit): resolve lint errors in agent tests and
deploy script
Prefix unused identifiers with underscores to satisfy Biome's
noUnusedFunctionParameters and noUnusedVariables rules.
Signed-off-by: MarioCadenas
---
packages/appkit/src/plugins/agent/tests/agent.test.ts | 2 +-
tools/deploy-agent-app.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/appkit/src/plugins/agent/tests/agent.test.ts b/packages/appkit/src/plugins/agent/tests/agent.test.ts
index 89e11fc9..357d68d0 100644
--- a/packages/appkit/src/plugins/agent/tests/agent.test.ts
+++ b/packages/appkit/src/plugins/agent/tests/agent.test.ts
@@ -189,7 +189,7 @@ describe("AgentPlugin", () => {
function createToolCallingAdapter(): AgentAdapter {
return {
async *run(
- input: AgentInput,
+ _input: AgentInput,
context: AgentRunContext,
): AsyncGenerator {
await context.executeTool("testplugin.action", {});
diff --git a/tools/deploy-agent-app.ts b/tools/deploy-agent-app.ts
index 3ac2ab3b..42d0d87f 100644
--- a/tools/deploy-agent-app.ts
+++ b/tools/deploy-agent-app.ts
@@ -5,7 +5,7 @@ import path from "node:path";
import { promisify } from "node:util";
import ora from "ora";
-const exec = promisify(execChildProcess);
+const _exec = promisify(execChildProcess);
const config = {
profile: process.env.DATABRICKS_PROFILE,
From 2637de8c32e29fd0485d0a3b0665617db739a151 Mon Sep 17 00:00:00 2001
From: MarioCadenas
Date: Thu, 16 Apr 2026 15:33:52 +0200
Subject: [PATCH 16/16] feat(appkit): add PluginContext mediator for
inter-plugin communication
Introduce a PluginContext class that the AppKit core creates and passes
to every plugin, mediating all inter-plugin communication. Plugins
request capabilities from the context (route mounting, tool discovery,
tool execution, lifecycle hooks) instead of holding direct references
to sibling plugin instances.
This eliminates:
- Plugin ordering fragility (routes are buffered until server registers)
- Frozen plugin snapshots (context returns live data)
- Duck-typed ToolProvider discovery (typed registry)
- Type-unsafe `as any` casts for plugin tool execution
The agent plugin is fully migrated to the new pattern. The server plugin
uses context.getPlugins() for route injection and shutdown. The old
config.plugins extraData for deferred plugins is removed.
Signed-off-by: MarioCadenas
---
packages/appkit/src/core/appkit.ts | 20 +-
packages/appkit/src/core/plugin-context.ts | 287 ++++++++++++++++
.../appkit/src/core/tests/databricks.test.ts | 15 +-
.../src/core/tests/plugin-context.test.ts | 325 ++++++++++++++++++
packages/appkit/src/plugin/plugin.ts | 5 +
packages/appkit/src/plugins/agent/agent.ts | 128 ++++---
.../src/plugins/agent/tests/agent.test.ts | 87 +++--
packages/appkit/src/plugins/agent/types.ts | 9 +-
packages/appkit/src/plugins/server/index.ts | 11 +-
.../src/plugins/server/tests/server.test.ts | 42 ++-
packages/appkit/src/plugins/server/types.ts | 2 -
11 files changed, 808 insertions(+), 123 deletions(-)
create mode 100644 packages/appkit/src/core/plugin-context.ts
create mode 100644 packages/appkit/src/core/tests/plugin-context.test.ts
diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts
index a2cba994..9e00da1d 100644
--- a/packages/appkit/src/core/appkit.ts
+++ b/packages/appkit/src/core/appkit.ts
@@ -13,14 +13,18 @@ import { ServiceContext } from "../context";
import { ResourceRegistry, ResourceType } from "../registry";
import type { TelemetryConfig } from "../telemetry";
import { TelemetryManager } from "../telemetry";
+import { isToolProvider, PluginContext } from "./plugin-context";
export class AppKit {
#pluginInstances: Record = {};
#setupPromises: Promise[] = [];
+ #context: PluginContext;
private constructor(config: { plugins: TPlugins }) {
const { plugins, ...globalConfig } = config;
+ this.#context = new PluginContext();
+
const pluginEntries = Object.entries(plugins);
const corePlugins = pluginEntries.filter(([_, p]) => {
@@ -35,20 +39,24 @@ export class AppKit {
for (const [name, pluginData] of corePlugins) {
if (pluginData) {
- this.createAndRegisterPlugin(globalConfig, name, pluginData);
+ this.createAndRegisterPlugin(globalConfig, name, pluginData, {
+ context: this.#context,
+ });
}
}
for (const [name, pluginData] of normalPlugins) {
if (pluginData) {
- this.createAndRegisterPlugin(globalConfig, name, pluginData);
+ this.createAndRegisterPlugin(globalConfig, name, pluginData, {
+ context: this.#context,
+ });
}
}
for (const [name, pluginData] of deferredPlugins) {
if (pluginData) {
this.createAndRegisterPlugin(globalConfig, name, pluginData, {
- plugins: this.#pluginInstances,
+ context: this.#context,
});
}
}
@@ -72,6 +80,11 @@ export class AppKit {
this.#pluginInstances[name] = pluginInstance;
+ this.#context.registerPlugin(name, pluginInstance);
+ if (isToolProvider(pluginInstance)) {
+ this.#context.registerToolProvider(name, pluginInstance);
+ }
+
this.#setupPromises.push(pluginInstance.setup());
const self = this;
@@ -199,6 +212,7 @@ export class AppKit {
const instance = new AppKit(mergedConfig);
await Promise.all(instance.#setupPromises);
+ await instance.#context.emitLifecycle("setup:complete");
return instance as unknown as PluginMap;
}
diff --git a/packages/appkit/src/core/plugin-context.ts b/packages/appkit/src/core/plugin-context.ts
new file mode 100644
index 00000000..c2801585
--- /dev/null
+++ b/packages/appkit/src/core/plugin-context.ts
@@ -0,0 +1,287 @@
+import type express from "express";
+import type { BasePlugin, ToolProvider } from "shared";
+import { createLogger } from "../logging/logger";
+import { TelemetryManager } from "../telemetry";
+
+const logger = createLogger("plugin-context");
+
+interface BufferedRoute {
+ method: string;
+ path: string;
+ handlers: express.RequestHandler[];
+}
+
+interface RouteTarget {
+ addExtension(fn: (app: express.Application) => void): void;
+}
+
+interface ToolProviderEntry {
+ plugin: BasePlugin & ToolProvider;
+ name: string;
+}
+
+type LifecycleEvent = "setup:complete" | "server:ready" | "shutdown";
+
+/**
+ * Mediator for inter-plugin communication.
+ *
+ * Created by AppKit core and passed to every plugin. Plugins request
+ * capabilities from the context instead of holding direct references
+ * to sibling plugin instances.
+ *
+ * Capabilities:
+ * - Route mounting with buffering (order-independent)
+ * - Typed ToolProvider registry (live, not snapshot-based)
+ * - User-scoped tool execution with automatic telemetry
+ * - Lifecycle hooks for plugin coordination
+ */
+export class PluginContext {
+ private routeBuffer: BufferedRoute[] = [];
+ private routeTarget: RouteTarget | null = null;
+ private toolProviders = new Map();
+ private plugins = new Map();
+ private lifecycleHooks = new Map<
+ LifecycleEvent,
+ Set<() => void | Promise>
+ >();
+ private telemetry = TelemetryManager.getProvider("plugin-context");
+
+ /**
+ * Register a route on the root Express application.
+ *
+ * If a route target (server plugin) has registered, the route is applied
+ * immediately. Otherwise it is buffered and flushed when a route target
+ * becomes available.
+ */
+ addRoute(
+ method: string,
+ path: string,
+ ...handlers: express.RequestHandler[]
+ ): void {
+ if (this.routeTarget) {
+ this.applyRoute({ method, path, handlers });
+ } else {
+ this.routeBuffer.push({ method, path, handlers });
+ }
+ }
+
+ /**
+ * Register middleware on the root Express application.
+ *
+ * Same buffering semantics as `addRoute`.
+ */
+ addMiddleware(path: string, ...handlers: express.RequestHandler[]): void {
+ if (this.routeTarget) {
+ this.applyMiddleware(path, handlers);
+ } else {
+ this.routeBuffer.push({ method: "use", path, handlers });
+ }
+ }
+
+ /**
+ * Called by the server plugin to opt in as the route target.
+ * Flushes all buffered routes via the server's `addExtension`.
+ */
+ registerAsRouteTarget(target: RouteTarget): void {
+ this.routeTarget = target;
+
+ for (const route of this.routeBuffer) {
+ if (route.method === "use") {
+ this.applyMiddleware(route.path, route.handlers);
+ } else {
+ this.applyRoute(route);
+ }
+ }
+ this.routeBuffer = [];
+ }
+
+ /**
+ * Register a plugin that implements the ToolProvider interface.
+ * Called by AppKit core after constructing each plugin.
+ */
+ registerToolProvider(name: string, plugin: BasePlugin & ToolProvider): void {
+ this.toolProviders.set(name, { plugin, name });
+ }
+
+ /**
+ * Register a plugin instance.
+ * Called by AppKit core after constructing each plugin.
+ */
+ registerPlugin(name: string, instance: BasePlugin): void {
+ this.plugins.set(name, instance);
+ }
+
+ /**
+ * Returns all registered plugin instances keyed by name.
+ * Used by the server plugin for route injection, client config,
+ * and shutdown coordination.
+ */
+ getPlugins(): Map {
+ return this.plugins;
+ }
+
+ /**
+ * Returns all registered ToolProvider plugins.
+ * Always returns the current set — not a frozen snapshot.
+ */
+ getToolProviders(): Array<{ name: string; provider: ToolProvider }> {
+ return Array.from(this.toolProviders.values()).map((entry) => ({
+ name: entry.name,
+ provider: entry.plugin,
+ }));
+ }
+
+ /**
+ * Execute a tool on a ToolProvider plugin with automatic user scoping
+ * and telemetry.
+ *
+ * The context:
+ * 1. Resolves the plugin by name
+ * 2. Calls `asUser(req)` for user-scoped execution
+ * 3. Wraps the call in a telemetry span with a 30s timeout
+ */
+ async executeTool(
+ req: express.Request,
+ pluginName: string,
+ toolName: string,
+ args: unknown,
+ signal?: AbortSignal,
+ ): Promise {
+ const entry = this.toolProviders.get(pluginName);
+ if (!entry) {
+ throw new Error(
+ `PluginContext: unknown plugin "${pluginName}". Available: ${Array.from(this.toolProviders.keys()).join(", ")}`,
+ );
+ }
+
+ const tracer = this.telemetry.getTracer();
+ const operationName = `executeTool:${pluginName}.${toolName}`;
+
+ return tracer.startActiveSpan(operationName, async (span) => {
+ const timeout = 30_000;
+ const timeoutSignal = AbortSignal.timeout(timeout);
+ const combinedSignal = signal
+ ? AbortSignal.any([signal, timeoutSignal])
+ : timeoutSignal;
+
+ try {
+ const userPlugin = (entry.plugin as any).asUser(req);
+ const result = await (userPlugin as ToolProvider).executeAgentTool(
+ toolName,
+ args,
+ combinedSignal,
+ );
+ span.setStatus({ code: 0 });
+ return result;
+ } catch (error) {
+ span.setStatus({
+ code: 2,
+ message:
+ error instanceof Error ? error.message : "Tool execution failed",
+ });
+ span.recordException(
+ error instanceof Error ? error : new Error(String(error)),
+ );
+ throw error;
+ } finally {
+ span.end();
+ }
+ });
+ }
+
+ /**
+ * Register a lifecycle hook callback.
+ */
+ onLifecycle(event: LifecycleEvent, fn: () => void | Promise): void {
+ let hooks = this.lifecycleHooks.get(event);
+ if (!hooks) {
+ hooks = new Set();
+ this.lifecycleHooks.set(event, hooks);
+ }
+ hooks.add(fn);
+ }
+
+ /**
+ * Emit a lifecycle event, calling all registered callbacks.
+ * Errors in individual callbacks are logged but do not prevent
+ * other callbacks from running.
+ *
+ * @internal Called by AppKit core only.
+ */
+ async emitLifecycle(event: LifecycleEvent): Promise {
+ const hooks = this.lifecycleHooks.get(event);
+ if (!hooks) return;
+
+ if (
+ event === "setup:complete" &&
+ this.routeBuffer.length > 0 &&
+ !this.routeTarget
+ ) {
+ logger.warn(
+ "%d buffered routes were never applied — no server plugin registered as route target",
+ this.routeBuffer.length,
+ );
+ }
+
+ for (const fn of hooks) {
+ try {
+ await fn();
+ } catch (error) {
+ logger.error("Lifecycle hook '%s' failed: %O", event, error);
+ }
+ }
+ }
+
+ /**
+ * Returns all registered plugin names.
+ */
+ getPluginNames(): string[] {
+ return Array.from(this.plugins.keys());
+ }
+
+ /**
+ * Check if a plugin with the given name is registered.
+ */
+ hasPlugin(name: string): boolean {
+ return this.plugins.has(name);
+ }
+
+ private applyRoute(route: BufferedRoute): void {
+ if (!this.routeTarget) return;
+ this.routeTarget.addExtension((app) => {
+ const method = route.method.toLowerCase() as keyof express.Application;
+ if (typeof app[method] === "function") {
+ (app[method] as (...a: unknown[]) => void)(
+ route.path,
+ ...route.handlers,
+ );
+ }
+ });
+ }
+
+ private applyMiddleware(
+ path: string,
+ handlers: express.RequestHandler[],
+ ): void {
+ if (!this.routeTarget) return;
+ this.routeTarget.addExtension((app) => {
+ app.use(path, ...handlers);
+ });
+ }
+}
+
+/**
+ * Type guard: checks whether a plugin implements the ToolProvider interface.
+ */
+export function isToolProvider(
+ plugin: unknown,
+): plugin is BasePlugin & ToolProvider {
+ return (
+ typeof plugin === "object" &&
+ plugin !== null &&
+ "getAgentTools" in plugin &&
+ typeof (plugin as ToolProvider).getAgentTools === "function" &&
+ "executeAgentTool" in plugin &&
+ typeof (plugin as ToolProvider).executeAgentTool === "function"
+ );
+}
diff --git a/packages/appkit/src/core/tests/databricks.test.ts b/packages/appkit/src/core/tests/databricks.test.ts
index c05345a6..9d3fe5f8 100644
--- a/packages/appkit/src/core/tests/databricks.test.ts
+++ b/packages/appkit/src/core/tests/databricks.test.ts
@@ -109,11 +109,11 @@ class DeferredTestPlugin implements BasePlugin {
name = "deferredTest";
setupCalled = false;
injectedConfig: any;
- injectedPlugins: any;
+ injectedContext: any;
constructor(config: any) {
this.injectedConfig = config;
- this.injectedPlugins = config.plugins;
+ this.injectedContext = config.context;
}
async setup() {
@@ -130,7 +130,7 @@ class DeferredTestPlugin implements BasePlugin {
return {
setupCalled: this.setupCalled,
injectedConfig: this.injectedConfig,
- injectedPlugins: this.injectedPlugins,
+ injectedContext: this.injectedContext,
};
}
}
@@ -276,7 +276,7 @@ describe("AppKit", () => {
expect(setupOrder).toEqual(["core", "normal", "deferred"]);
});
- test("should provide plugin instances to deferred plugins", async () => {
+ test("should provide PluginContext to deferred plugins", async () => {
const pluginData = [
{ plugin: CoreTestPlugin, config: {}, name: "coreTest" },
{ plugin: DeferredTestPlugin, config: {}, name: "deferredTest" },
@@ -284,10 +284,9 @@ describe("AppKit", () => {
const instance = (await createApp({ plugins: pluginData })) as any;
- // Deferred plugins receive plugin instances (not SDKs) for internal use
- expect(instance.deferredTest.injectedPlugins).toBeDefined();
- expect(instance.deferredTest.injectedPlugins.coreTest).toBeInstanceOf(
- CoreTestPlugin,
+ expect(instance.deferredTest.injectedContext).toBeDefined();
+ expect(instance.deferredTest.injectedContext.hasPlugin("coreTest")).toBe(
+ true,
);
});
diff --git a/packages/appkit/src/core/tests/plugin-context.test.ts b/packages/appkit/src/core/tests/plugin-context.test.ts
new file mode 100644
index 00000000..276c5502
--- /dev/null
+++ b/packages/appkit/src/core/tests/plugin-context.test.ts
@@ -0,0 +1,325 @@
+import type { AgentToolDefinition } from "shared";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { isToolProvider, PluginContext } from "../plugin-context";
+
+vi.mock("../../telemetry", () => ({
+ TelemetryManager: {
+ getProvider: () => ({
+ getTracer: () => ({
+ startActiveSpan: (_name: string, fn: (span: any) => any) => {
+ const span = {
+ setStatus: vi.fn(),
+ recordException: vi.fn(),
+ end: vi.fn(),
+ };
+ return fn(span);
+ },
+ }),
+ }),
+ },
+}));
+
+vi.mock("../../logging/logger", () => ({
+ createLogger: () => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ }),
+}));
+
+function createMockToolProvider(tools: AgentToolDefinition[] = []) {
+ const mock = {
+ name: "mock-plugin",
+ setup: vi.fn().mockResolvedValue(undefined),
+ injectRoutes: vi.fn(),
+ getEndpoints: vi.fn().mockReturnValue({}),
+ getAgentTools: vi.fn().mockReturnValue(tools),
+ executeAgentTool: vi.fn().mockResolvedValue("tool-result"),
+ asUser: vi.fn().mockReturnThis(),
+ };
+ return mock as any;
+}
+
+describe("PluginContext", () => {
+ let ctx: PluginContext;
+
+ beforeEach(() => {
+ ctx = new PluginContext();
+ });
+
+ describe("route buffering", () => {
+ test("addRoute buffers when no route target exists", () => {
+ const handler = vi.fn();
+ ctx.addRoute("post", "/invocations", handler);
+
+ expect(ctx.getPluginNames()).toEqual([]);
+ });
+
+ test("flushRoutes applies buffered routes via addExtension", () => {
+ const handler = vi.fn();
+ ctx.addRoute("post", "/invocations", handler);
+
+ const addExtension = vi.fn();
+ ctx.registerAsRouteTarget({ addExtension });
+
+ expect(addExtension).toHaveBeenCalledTimes(1);
+ const extensionFn = addExtension.mock.calls[0][0];
+
+ const mockApp = { post: vi.fn() };
+ extensionFn(mockApp);
+ expect(mockApp.post).toHaveBeenCalledWith("/invocations", handler);
+ });
+
+ test("addRoute called after registerAsRouteTarget applies immediately", () => {
+ const addExtension = vi.fn();
+ ctx.registerAsRouteTarget({ addExtension });
+
+ const handler = vi.fn();
+ ctx.addRoute("get", "/health", handler);
+
+ expect(addExtension).toHaveBeenCalledTimes(1);
+ const extensionFn = addExtension.mock.calls[0][0];
+
+ const mockApp = { get: vi.fn() };
+ extensionFn(mockApp);
+ expect(mockApp.get).toHaveBeenCalledWith("/health", handler);
+ });
+
+ test("addRoute supports middleware chains", () => {
+ const auth = vi.fn();
+ const handler = vi.fn();
+
+ const addExtension = vi.fn();
+ ctx.registerAsRouteTarget({ addExtension });
+
+ ctx.addRoute("post", "/api", auth, handler);
+
+ const extensionFn = addExtension.mock.calls[0][0];
+ const mockApp = { post: vi.fn() };
+ extensionFn(mockApp);
+ expect(mockApp.post).toHaveBeenCalledWith("/api", auth, handler);
+ });
+
+ test("addMiddleware buffers and applies via use()", () => {
+ const handler = vi.fn();
+ ctx.addMiddleware("/api", handler);
+
+ const addExtension = vi.fn();
+ ctx.registerAsRouteTarget({ addExtension });
+
+ expect(addExtension).toHaveBeenCalledTimes(1);
+ const extensionFn = addExtension.mock.calls[0][0];
+
+ const mockApp = { use: vi.fn() };
+ extensionFn(mockApp);
+ expect(mockApp.use).toHaveBeenCalledWith("/api", handler);
+ });
+
+ test("multiple buffered routes are all applied on registration", () => {
+ const h1 = vi.fn();
+ const h2 = vi.fn();
+ ctx.addRoute("post", "/a", h1);
+ ctx.addRoute("get", "/b", h2);
+
+ const addExtension = vi.fn();
+ ctx.registerAsRouteTarget({ addExtension });
+
+ expect(addExtension).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe("ToolProvider registry", () => {
+ test("registerToolProvider makes provider visible via getToolProviders", () => {
+ const provider = createMockToolProvider([
+ {
+ name: "query",
+ description: "Run query",
+ parameters: { type: "object" },
+ },
+ ]);
+
+ ctx.registerToolProvider("analytics", provider);
+
+ const providers = ctx.getToolProviders();
+ expect(providers).toHaveLength(1);
+ expect(providers[0].name).toBe("analytics");
+ expect(providers[0].provider.getAgentTools()).toHaveLength(1);
+ });
+
+ test("getToolProviders returns all registered providers", () => {
+ ctx.registerToolProvider("analytics", createMockToolProvider());
+ ctx.registerToolProvider("files", createMockToolProvider());
+ ctx.registerToolProvider("genie", createMockToolProvider());
+
+ expect(ctx.getToolProviders()).toHaveLength(3);
+ });
+
+ test("getToolProviders returns current set, not snapshot", () => {
+ const before = ctx.getToolProviders();
+ expect(before).toHaveLength(0);
+
+ ctx.registerToolProvider("analytics", createMockToolProvider());
+
+ const after = ctx.getToolProviders();
+ expect(after).toHaveLength(1);
+ });
+ });
+
+ describe("executeTool", () => {
+ test("calls asUser(req).executeAgentTool on the correct plugin", async () => {
+ const provider = createMockToolProvider();
+ ctx.registerToolProvider("analytics", provider);
+
+ const mockReq = { headers: {} } as any;
+ await ctx.executeTool(mockReq, "analytics", "query", { sql: "SELECT 1" });
+
+ expect(provider.asUser).toHaveBeenCalledWith(mockReq);
+ expect(provider.executeAgentTool).toHaveBeenCalledWith(
+ "query",
+ { sql: "SELECT 1" },
+ expect.any(Object),
+ );
+ });
+
+ test("throws for unknown plugin name", async () => {
+ const mockReq = { headers: {} } as any;
+
+ await expect(
+ ctx.executeTool(mockReq, "nonexistent", "query", {}),
+ ).rejects.toThrow('unknown plugin "nonexistent"');
+ });
+
+ test("propagates tool execution errors", async () => {
+ const provider = createMockToolProvider();
+ (provider.executeAgentTool as any).mockRejectedValue(
+ new Error("Query failed"),
+ );
+ ctx.registerToolProvider("analytics", provider);
+
+ const mockReq = { headers: {} } as any;
+
+ await expect(
+ ctx.executeTool(mockReq, "analytics", "query", {}),
+ ).rejects.toThrow("Query failed");
+ });
+
+ test("passes abort signal to executeAgentTool", async () => {
+ const provider = createMockToolProvider();
+ ctx.registerToolProvider("analytics", provider);
+
+ const controller = new AbortController();
+ const mockReq = { headers: {} } as any;
+
+ await ctx.executeTool(
+ mockReq,
+ "analytics",
+ "query",
+ {},
+ controller.signal,
+ );
+
+ const callArgs = (provider.executeAgentTool as any).mock.calls[0];
+ expect(callArgs[2]).toBeDefined();
+ });
+ });
+
+ describe("lifecycle hooks", () => {
+ test("onLifecycle registers callback, emitLifecycle invokes it", async () => {
+ const fn = vi.fn();
+ ctx.onLifecycle("setup:complete", fn);
+
+ await ctx.emitLifecycle("setup:complete");
+
+ expect(fn).toHaveBeenCalledTimes(1);
+ });
+
+ test("multiple callbacks for the same event all fire", async () => {
+ const fn1 = vi.fn();
+ const fn2 = vi.fn();
+ ctx.onLifecycle("setup:complete", fn1);
+ ctx.onLifecycle("setup:complete", fn2);
+
+ await ctx.emitLifecycle("setup:complete");
+
+ expect(fn1).toHaveBeenCalledTimes(1);
+ expect(fn2).toHaveBeenCalledTimes(1);
+ });
+
+ test("callback error does not prevent other callbacks from running", async () => {
+ const fn1 = vi.fn().mockRejectedValue(new Error("fail"));
+ const fn2 = vi.fn();
+ ctx.onLifecycle("shutdown", fn1);
+ ctx.onLifecycle("shutdown", fn2);
+
+ await ctx.emitLifecycle("shutdown");
+
+ expect(fn1).toHaveBeenCalled();
+ expect(fn2).toHaveBeenCalled();
+ });
+
+ test("emitLifecycle with no registered hooks does nothing", async () => {
+ await expect(ctx.emitLifecycle("server:ready")).resolves.toBeUndefined();
+ });
+ });
+
+ describe("plugin metadata", () => {
+ const stubPlugin = { name: "stub" } as any;
+
+ test("getPluginNames returns all registered names", () => {
+ ctx.registerPlugin("analytics", stubPlugin);
+ ctx.registerPlugin("server", stubPlugin);
+ ctx.registerPlugin("agent", stubPlugin);
+
+ const names = ctx.getPluginNames();
+ expect(names).toContain("analytics");
+ expect(names).toContain("server");
+ expect(names).toContain("agent");
+ expect(names).toHaveLength(3);
+ });
+
+ test("hasPlugin returns true for registered plugins", () => {
+ ctx.registerPlugin("analytics", stubPlugin);
+
+ expect(ctx.hasPlugin("analytics")).toBe(true);
+ expect(ctx.hasPlugin("nonexistent")).toBe(false);
+ });
+
+ test("getPlugins returns all registered instances", () => {
+ const p1 = { name: "analytics" } as any;
+ const p2 = { name: "server" } as any;
+ ctx.registerPlugin("analytics", p1);
+ ctx.registerPlugin("server", p2);
+
+ const plugins = ctx.getPlugins();
+ expect(plugins.size).toBe(2);
+ expect(plugins.get("analytics")).toBe(p1);
+ expect(plugins.get("server")).toBe(p2);
+ });
+ });
+});
+
+describe("isToolProvider", () => {
+ test("returns true for objects with getAgentTools and executeAgentTool", () => {
+ const provider = createMockToolProvider();
+ expect(isToolProvider(provider)).toBe(true);
+ });
+
+ test("returns false for null", () => {
+ expect(isToolProvider(null)).toBe(false);
+ });
+
+ test("returns false for objects missing executeAgentTool", () => {
+ expect(isToolProvider({ getAgentTools: vi.fn() })).toBe(false);
+ });
+
+ test("returns false for objects missing getAgentTools", () => {
+ expect(isToolProvider({ executeAgentTool: vi.fn() })).toBe(false);
+ });
+
+ test("returns false for non-objects", () => {
+ expect(isToolProvider("string")).toBe(false);
+ expect(isToolProvider(42)).toBe(false);
+ expect(isToolProvider(undefined)).toBe(false);
+ });
+});
diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts
index 36dab543..a76bbbd6 100644
--- a/packages/appkit/src/plugin/plugin.ts
+++ b/packages/appkit/src/plugin/plugin.ts
@@ -19,6 +19,7 @@ import {
ServiceContext,
type UserContext,
} from "../context";
+import type { PluginContext } from "../core/plugin-context";
import { AuthenticationError } from "../errors";
import { createLogger } from "../logging/logger";
import { StreamManager } from "../stream";
@@ -153,6 +154,7 @@ export abstract class Plugin<
protected devFileReader: DevFileReader;
protected streamManager: StreamManager;
protected telemetry: ITelemetry;
+ protected context?: PluginContext;
/** Registered endpoints for this plugin */
private registeredEndpoints: PluginEndpointMap = {};
@@ -183,6 +185,9 @@ export abstract class Plugin<
this.cache = CacheManager.getInstanceSync();
this.app = new AppManager();
this.devFileReader = DevFileReader.getInstance();
+ this.context = (config as Record).context as
+ | PluginContext
+ | undefined;
this.isReady = true;
}
diff --git a/packages/appkit/src/plugins/agent/agent.ts b/packages/appkit/src/plugins/agent/agent.ts
index f7d1ddef..b44bb9d2 100644
--- a/packages/appkit/src/plugins/agent/agent.ts
+++ b/packages/appkit/src/plugins/agent/agent.ts
@@ -8,7 +8,6 @@ import type {
Message,
PluginPhase,
ResponseStreamEvent,
- ToolProvider,
} from "shared";
import { createLogger } from "../../logging/logger";
import { Plugin, toPlugin } from "../../plugin";
@@ -30,17 +29,6 @@ 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";
@@ -87,40 +75,32 @@ export class AgentPlugin extends Plugin {
}
private mountInvocationsRoute() {
- const serverPlugin = this.config.plugins?.server as
- | { addExtension?: (fn: (app: any) => void) => void }
- | undefined;
-
- if (!serverPlugin?.addExtension) return;
+ if (!this.context) return;
- serverPlugin.addExtension((app: import("express").Application) => {
- app.post(
- "/invocations",
- (req: express.Request, res: express.Response) => {
- this._handleInvocations(req, res);
- },
- );
- });
+ this.context.addRoute(
+ "post",
+ "/invocations",
+ (req: express.Request, res: express.Response) => {
+ this._handleInvocations(req, res);
+ },
+ );
- logger.info("Mounted POST /invocations route");
+ logger.info("Registered POST /invocations route via PluginContext");
}
private async collectTools() {
- // 1. Auto-discover from sibling ToolProvider plugins
- const plugins = this.config.plugins;
- if (plugins) {
- for (const [pluginName, pluginInstance] of Object.entries(plugins)) {
- if (pluginName === "agent") continue;
- if (!isToolProvider(pluginInstance)) continue;
-
- const tools = (pluginInstance as ToolProvider).getAgentTools();
+ // 1. Auto-discover from sibling ToolProvider plugins via PluginContext
+ if (this.context) {
+ for (const {
+ name: pluginName,
+ provider,
+ } of this.context.getToolProviders()) {
+ const tools = provider.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;
- },
+ pluginName,
def: { ...tool, name: qualifiedName },
localName: tool.name,
});
@@ -399,41 +379,51 @@ export class AgentPlugin extends Plugin {
const entry = self.toolIndex.get(qualifiedName);
if (!entry) throw new Error(`Unknown tool: ${qualifiedName}`);
- const result = await self.execute(
- async (execSignal) => {
- switch (entry.source) {
- case "plugin": {
- const target = (entry.plugin as any).asUser(req);
- 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");
+ let result: unknown;
+
+ if (entry.source === "plugin" && self.context) {
+ result = await self.context.executeTool(
+ req,
+ entry.pluginName,
+ entry.localName,
+ args,
+ signal,
+ );
+ } else {
+ result = await self.execute(
+ async (_execSignal) => {
+ switch (entry.source) {
+ case "plugin":
+ throw new Error("Plugin tool execution requires PluginContext");
+ case "function":
+ return entry.functionTool.execute(
+ args as Record,
+ );
+ case "mcp": {
+ if (!self.mcpClient) {
+ throw new Error("MCP client not connected");
+ }
+ const oboToken = req.headers["x-forwarded-access-token"];
+ const mcpAuth =
+ typeof oboToken === "string"
+ ? { Authorization: `Bearer ${oboToken}` }
+ : undefined;
+ return self.mcpClient.callTool(
+ entry.mcpToolName,
+ args,
+ mcpAuth,
+ );
}
- const oboToken = req.headers["x-forwarded-access-token"];
- const mcpAuth =
- typeof oboToken === "string"
- ? { Authorization: `Bearer ${oboToken}` }
- : undefined;
- return self.mcpClient.callTool(entry.mcpToolName, args, mcpAuth);
}
- }
- },
- {
- default: {
- telemetryInterceptor: { enabled: true },
- timeout: 30_000,
},
- },
- );
+ {
+ default: {
+ telemetryInterceptor: { enabled: true },
+ timeout: 30_000,
+ },
+ },
+ );
+ }
if (result === undefined) {
return `Error: Tool "${qualifiedName}" execution failed`;
diff --git a/packages/appkit/src/plugins/agent/tests/agent.test.ts b/packages/appkit/src/plugins/agent/tests/agent.test.ts
index 357d68d0..128e6157 100644
--- a/packages/appkit/src/plugins/agent/tests/agent.test.ts
+++ b/packages/appkit/src/plugins/agent/tests/agent.test.ts
@@ -13,6 +13,7 @@ import type {
ToolProvider,
} from "shared";
import { beforeEach, describe, expect, test, vi } from "vitest";
+import { PluginContext } from "../../../core/plugin-context";
import { AgentPlugin } from "../agent";
vi.mock("../../../cache", () => ({
@@ -42,7 +43,16 @@ vi.mock("../../../context", async (importOriginal) => {
vi.mock("../../../telemetry", () => ({
TelemetryManager: {
getProvider: vi.fn(() => ({
- getTracer: vi.fn(),
+ getTracer: vi.fn(() => ({
+ startActiveSpan: (_name: string, fn: (span: any) => any) => {
+ const span = {
+ setStatus: vi.fn(),
+ recordException: vi.fn(),
+ end: vi.fn(),
+ };
+ return fn(span);
+ },
+ })),
getMeter: vi.fn(),
getLogger: vi.fn(),
emit: vi.fn(),
@@ -61,10 +71,25 @@ function createMockToolProvider(
tools: AgentToolDefinition[],
): ToolProvider & { asUser: any } {
return {
+ name: "mock-plugin",
getAgentTools: () => tools,
executeAgentTool: vi.fn().mockResolvedValue({ result: "ok" }),
asUser: vi.fn().mockReturnThis(),
- };
+ } as any;
+}
+
+function createMockContext(
+ providers: Array<{
+ name: string;
+ provider: ToolProvider & { asUser: any };
+ }> = [],
+): PluginContext {
+ const ctx = new PluginContext();
+ for (const { name, provider } of providers) {
+ ctx.registerToolProvider(name, provider as any);
+ ctx.registerPlugin(name, provider as any);
+ }
+ return ctx;
}
async function* mockAdapterRun(): AsyncGenerator {
@@ -83,7 +108,7 @@ describe("AgentPlugin", () => {
setupDatabricksEnv();
});
- test("collectTools discovers ToolProvider plugins", async () => {
+ test("collectTools discovers ToolProvider plugins via context", async () => {
const mockProvider = createMockToolProvider([
{
name: "query",
@@ -92,10 +117,14 @@ describe("AgentPlugin", () => {
},
]);
+ const context = createMockContext([
+ { name: "analytics", provider: mockProvider },
+ ]);
+
const plugin = new AgentPlugin({
name: "agent",
- plugins: { analytics: mockProvider },
- });
+ context,
+ } as any);
await plugin.setup();
@@ -106,20 +135,14 @@ describe("AgentPlugin", () => {
expect(tools[0].name).toBe("analytics.query");
});
- test("skips non-ToolProvider plugins", async () => {
+ test("works with no context (backward compat)", 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);
+ expect(tools).toEqual([]);
});
test("registerAgent and resolveAgent", () => {
@@ -128,7 +151,6 @@ describe("AgentPlugin", () => {
plugin.exports().registerAgent("assistant", adapter);
- // The first registered agent becomes the default
const tools = plugin.exports().getTools();
expect(tools).toEqual([]);
});
@@ -177,15 +199,39 @@ describe("AgentPlugin", () => {
expect(tools[0].name).toBe("myTool");
});
- test("executeTool always calls asUser(req) for plugin tools, even without requiresUserContext", async () => {
+ test("mountInvocationsRoute registers via context.addRoute", async () => {
+ const context = createMockContext();
+ const addRouteSpy = vi.spyOn(context, "addRoute");
+
+ const plugin = new AgentPlugin({
+ name: "agent",
+ agents: { assistant: createMockAdapter() },
+ context,
+ } as any);
+
+ await plugin.setup();
+
+ expect(addRouteSpy).toHaveBeenCalledWith(
+ "post",
+ "/invocations",
+ expect.any(Function),
+ );
+ });
+
+ test("executeTool calls context.executeTool for plugin tools", async () => {
const mockProvider = createMockToolProvider([
{
name: "action",
- description: "An action without requiresUserContext",
+ description: "An action",
parameters: { type: "object", properties: {} },
},
]);
+ const context = createMockContext([
+ { name: "testplugin", provider: mockProvider },
+ ]);
+ const executeToolSpy = vi.spyOn(context, "executeTool");
+
function createToolCallingAdapter(): AgentAdapter {
return {
async *run(
@@ -201,8 +247,8 @@ describe("AgentPlugin", () => {
const plugin = new AgentPlugin({
name: "agent",
agents: { assistant: createToolCallingAdapter() },
- plugins: { testplugin: mockProvider },
- });
+ context,
+ } as any);
await plugin.setup();
const { router, getHandler } = createMockRouter();
@@ -220,8 +266,9 @@ describe("AgentPlugin", () => {
await handler(req, res);
- expect(mockProvider.asUser).toHaveBeenCalledWith(req);
- expect(mockProvider.executeAgentTool).toHaveBeenCalledWith(
+ expect(executeToolSpy).toHaveBeenCalledWith(
+ req,
+ "testplugin",
"action",
{},
expect.anything(),
diff --git a/packages/appkit/src/plugins/agent/types.ts b/packages/appkit/src/plugins/agent/types.ts
index e86242a1..8a11ba78 100644
--- a/packages/appkit/src/plugins/agent/types.ts
+++ b/packages/appkit/src/plugins/agent/types.ts
@@ -3,7 +3,6 @@ import type {
AgentToolDefinition,
BasePluginConfig,
ThreadStore,
- ToolProvider,
} from "shared";
import type { FunctionTool } from "./tools/function-tool";
import type { HostedTool } from "./tools/hosted-tools";
@@ -21,7 +20,7 @@ export interface AgentPluginConfig extends BasePluginConfig {
export type ToolEntry =
| {
source: "plugin";
- plugin: ToolProvider & { asUser(req: any): any };
+ pluginName: string;
def: AgentToolDefinition;
localName: string;
}
@@ -41,8 +40,4 @@ export type RegisteredAgent = {
adapter: AgentAdapter;
};
-export type {
- AgentAdapter,
- AgentToolDefinition,
- ToolProvider,
-} from "shared";
+export type { AgentAdapter, AgentToolDefinition } from "shared";
diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts
index 75d3e1d0..7a38e429 100644
--- a/packages/appkit/src/plugins/server/index.ts
+++ b/packages/appkit/src/plugins/server/index.ts
@@ -63,6 +63,7 @@ export class ServerPlugin extends Plugin {
instrumentations.http,
instrumentations.express,
]);
+ this.context?.registerAsRouteTarget(this);
}
/** Setup the server plugin. */
@@ -203,14 +204,15 @@ export class ServerPlugin extends Plugin {
const endpoints: PluginEndpoints = {};
const pluginConfigs: PluginClientConfigs = {};
- if (!this.config.plugins) return { endpoints, pluginConfigs };
+ const plugins = this.context?.getPlugins();
+ if (!plugins || plugins.size === 0) return { endpoints, pluginConfigs };
this.serverApplication.get("/health", (_, res) => {
res.status(200).json({ status: "ok" });
});
this.registerEndpoint("health", "/health");
- for (const plugin of Object.values(this.config.plugins)) {
+ for (const plugin of plugins.values()) {
if (EXCLUDED_PLUGINS.includes(plugin.name)) continue;
if (plugin?.injectRoutes && typeof plugin.injectRoutes === "function") {
@@ -359,8 +361,9 @@ export class ServerPlugin extends Plugin {
}
// 1. abort active operations from plugins
- if (this.config.plugins) {
- for (const plugin of Object.values(this.config.plugins)) {
+ const plugins = this.context?.getPlugins();
+ if (plugins) {
+ for (const plugin of plugins.values()) {
if (plugin.abortActiveOperations) {
try {
plugin.abortActiveOperations();
diff --git a/packages/appkit/src/plugins/server/tests/server.test.ts b/packages/appkit/src/plugins/server/tests/server.test.ts
index 22f18129..52d15845 100644
--- a/packages/appkit/src/plugins/server/tests/server.test.ts
+++ b/packages/appkit/src/plugins/server/tests/server.test.ts
@@ -1,4 +1,6 @@
+import type { BasePlugin } from "shared";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { PluginContext } from "../../../core/plugin-context";
// Use vi.hoisted for mocks that need to be available before module loading
const {
@@ -171,6 +173,14 @@ import { RemoteTunnelController } from "../remote-tunnel/remote-tunnel-controlle
import { StaticServer } from "../static-server";
import { ViteDevServer } from "../vite-dev-server";
+function createContextWithPlugins(plugins: Record): PluginContext {
+ const ctx = new PluginContext();
+ for (const [name, instance] of Object.entries(plugins)) {
+ ctx.registerPlugin(name, instance as BasePlugin);
+ }
+ return ctx;
+}
+
describe("ServerPlugin", () => {
let originalEnv: NodeJS.ProcessEnv;
@@ -340,7 +350,7 @@ describe("ServerPlugin", () => {
process.env.NODE_ENV = "production";
const injectRoutes = vi.fn();
- const plugins: any = {
+ const testPlugins: any = {
"test-plugin": {
name: "test-plugin",
injectRoutes,
@@ -348,7 +358,10 @@ describe("ServerPlugin", () => {
},
};
- const plugin = new ServerPlugin({ autoStart: false, plugins });
+ const plugin = new ServerPlugin({
+ autoStart: false,
+ context: createContextWithPlugins(testPlugins),
+ } as any);
await plugin.start();
const routerFn = (express as any).Router as ReturnType;
@@ -386,7 +399,10 @@ describe("ServerPlugin", () => {
},
};
- const plugin = new ServerPlugin({ autoStart: false, plugins });
+ const plugin = new ServerPlugin({
+ autoStart: false,
+ context: createContextWithPlugins(plugins),
+ } as any);
await plugin.start();
expect(plugins["plugin-a"].clientConfig).toHaveBeenCalled();
@@ -413,7 +429,10 @@ describe("ServerPlugin", () => {
},
};
- const plugin = new ServerPlugin({ autoStart: false, plugins });
+ const plugin = new ServerPlugin({
+ autoStart: false,
+ context: createContextWithPlugins(plugins),
+ } as any);
await plugin.start();
expect(plugins["plugin-null"].clientConfig).toHaveBeenCalled();
@@ -444,7 +463,10 @@ describe("ServerPlugin", () => {
},
};
- const plugin = new ServerPlugin({ autoStart: false, plugins });
+ const plugin = new ServerPlugin({
+ autoStart: false,
+ context: createContextWithPlugins(plugins),
+ } as any);
await expect(plugin.start()).resolves.toBeDefined();
expect(mockLoggerError).toHaveBeenCalledWith(
"Plugin '%s' clientConfig() failed, skipping its config: %O",
@@ -608,19 +630,19 @@ describe("ServerPlugin", () => {
const plugin = new ServerPlugin({
autoStart: false,
- plugins: {
+ context: createContextWithPlugins({
ok: {
name: "ok",
abortActiveOperations: vi.fn(),
- } as any,
+ },
bad: {
name: "bad",
abortActiveOperations: vi.fn(() => {
throw new Error("boom");
}),
- } as any,
- },
- });
+ },
+ }),
+ } as any);
// pretend started
(plugin as any).server = mockHttpServer;
diff --git a/packages/appkit/src/plugins/server/types.ts b/packages/appkit/src/plugins/server/types.ts
index e187cacc..84a2327e 100644
--- a/packages/appkit/src/plugins/server/types.ts
+++ b/packages/appkit/src/plugins/server/types.ts
@@ -1,9 +1,7 @@
import type { BasePluginConfig } from "shared";
-import type { Plugin } from "../../plugin";
export interface ServerConfig extends BasePluginConfig {
port?: number;
- plugins?: Record;
staticPath?: string;
autoStart?: boolean;
host?: string;