- Chat with a Databricks Model Serving endpoint using streaming
- completions with real-time SSE responses.
+ 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/config/agents/assistant.md b/apps/dev-playground/config/agents/assistant.md
new file mode 100644
index 00000000..ea99d47b
--- /dev/null
+++ b/apps/dev-playground/config/agents/assistant.md
@@ -0,0 +1,6 @@
+---
+endpoint: databricks-claude-sonnet-4-5
+default: true
+---
+
+You are a helpful data assistant. Use the available tools to query data and help users with their analysis.
diff --git a/apps/dev-playground/config/agents/autocomplete.md b/apps/dev-playground/config/agents/autocomplete.md
new file mode 100644
index 00000000..fafe3330
--- /dev/null
+++ b/apps/dev-playground/config/agents/autocomplete.md
@@ -0,0 +1,6 @@
+---
+endpoint: databricks-gemini-3-1-flash-lite
+maxSteps: 1
+---
+
+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.
diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts
index 913f547c..b782dcbb 100644
--- a/apps/dev-playground/server/index.ts
+++ b/apps/dev-playground/server/index.ts
@@ -1,15 +1,17 @@
import "reflect-metadata";
import {
+ agents,
analytics,
+ createAgent,
createApp,
files,
+ fromPlugin,
genie,
server,
- serving,
+ tool,
} from "@databricks/appkit";
import { WorkspaceClient } from "@databricks/sdk-experimental";
-// TODO: re-enable once vector-search is exported from @databricks/appkit
-// import { vectorSearch } from "@databricks/appkit";
+import { z } from "zod";
import { lakebaseExamples } from "./lakebase-examples-plugin";
import { reconnect } from "./reconnect-plugin";
import { telemetryExamples } from "./telemetry-example-plugin";
@@ -24,6 +26,23 @@ function createMockClient() {
return client;
}
+// Code-defined demo agent showing the fromPlugin() API alongside the
+// markdown-driven agents in config/agents/.
+const helper = createAgent({
+ instructions:
+ "You are a demo helper. Use analytics tools to answer data questions, " +
+ "or get_weather for light small-talk.",
+ tools: {
+ ...fromPlugin(analytics),
+ get_weather: tool({
+ name: "get_weather",
+ description: "Get the current weather for a city",
+ schema: z.object({ city: z.string().describe("City name") }),
+ execute: async ({ city }) => `The weather in ${city} is sunny, 22°C`,
+ }),
+ },
+});
+
createApp({
plugins: [
server({ autoStart: false }),
@@ -35,18 +54,7 @@ createApp({
}),
lakebaseExamples(),
files(),
- serving(),
- // TODO: re-enable once vector-search is exported from @databricks/appkit
- // vectorSearch({
- // indexes: {
- // demo: {
- // indexName:
- // process.env.DATABRICKS_VS_INDEX_NAME ?? "catalog.schema.index",
- // columns: ["id", "text", "title"],
- // queryType: "hybrid",
- // },
- // },
- // }),
+ agents({ agents: { helper } }),
],
...(process.env.APPKIT_E2E_TEST && { client: createMockClient() }),
}).then((appkit) => {
diff --git a/docs/docs/api/appkit/Class.Plugin.md b/docs/docs/api/appkit/Class.Plugin.md
index 06e558dc..34034537 100644
--- a/docs/docs/api/appkit/Class.Plugin.md
+++ b/docs/docs/api/appkit/Class.Plugin.md
@@ -136,6 +136,14 @@ protected config: TConfig;
***
+### context?
+
+```ts
+protected optional context: PluginContext;
+```
+
+***
+
### devFileReader
```ts
@@ -244,6 +252,42 @@ AuthenticationError if user token is not available in request headers (productio
***
+### attachContext()
+
+```ts
+attachContext(deps: {
+ context?: unknown;
+ telemetryConfig?: TelemetryOptions;
+}): void;
+```
+
+Binds runtime dependencies (telemetry provider, cache, plugin context) to
+this plugin. Called by `AppKit._createApp` after construction and before
+`setup()`. Idempotent: safe to call if the constructor already bound them
+eagerly. Kept separate so factories can eagerly construct plugin instances
+without running this before `TelemetryManager.initialize()` /
+`CacheManager.getInstance()` have run.
+
+#### Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `deps` | \{ `context?`: `unknown`; `telemetryConfig?`: `TelemetryOptions`; \} |
+| `deps.context?` | `unknown` |
+| `deps.telemetryConfig?` | `TelemetryOptions` |
+
+#### Returns
+
+`void`
+
+#### Implementation of
+
+```ts
+BasePlugin.attachContext
+```
+
+***
+
### clientConfig()
```ts
diff --git a/docs/docs/api/appkit/Function.createAgent.md b/docs/docs/api/appkit/Function.createAgent.md
new file mode 100644
index 00000000..61064e51
--- /dev/null
+++ b/docs/docs/api/appkit/Function.createAgent.md
@@ -0,0 +1,35 @@
+# Function: createAgent()
+
+```ts
+function createAgent(def: AgentDefinition): AgentDefinition;
+```
+
+Pure factory for agent definitions. Returns the passed-in definition after
+cycle-detecting the sub-agent graph. Accepts the full `AgentDefinition` shape
+and is safe to call at module top-level.
+
+The returned value is a plain `AgentDefinition` — no adapter construction,
+no side effects. Register it with `agents({ agents: { name: def } })` or run
+it standalone via `runAgent(def, input)`.
+
+## Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `def` | [`AgentDefinition`](Interface.AgentDefinition.md) |
+
+## Returns
+
+[`AgentDefinition`](Interface.AgentDefinition.md)
+
+## Example
+
+```ts
+const support = createAgent({
+ instructions: "You help customers.",
+ model: "databricks-claude-sonnet-4-5",
+ tools: {
+ get_weather: tool({ ... }),
+ },
+});
+```
diff --git a/docs/docs/api/appkit/Function.fromPlugin.md b/docs/docs/api/appkit/Function.fromPlugin.md
new file mode 100644
index 00000000..5262ef54
--- /dev/null
+++ b/docs/docs/api/appkit/Function.fromPlugin.md
@@ -0,0 +1,50 @@
+# Function: fromPlugin()
+
+```ts
+function fromPlugin(factory: F, opts?: ToolkitOptions): FromPluginSpread;
+```
+
+Reference a plugin's tools inside an `AgentDefinition.tools` record without
+naming the plugin instance. The returned spread-friendly object carries a
+symbol-keyed marker that the agents plugin resolves against registered
+`ToolProvider`s at setup time.
+
+The factory argument must come from `toPlugin` (or any function that
+carries a `pluginName` field). `fromPlugin` reads `factory.pluginName`
+synchronously — it does not construct an instance.
+
+If the referenced plugin is also registered in `createApp({ plugins })`, the
+same runtime instance is used for dispatch. If the plugin is missing,
+`AgentsPlugin.setup()` throws with a clear `Available: …` listing.
+
+## Type Parameters
+
+| Type Parameter |
+| ------ |
+| `F` *extends* `NamedPluginFactory` |
+
+## Parameters
+
+| Parameter | Type | Description |
+| ------ | ------ | ------ |
+| `factory` | `F` | A plugin factory produced by `toPlugin`. Must expose a `pluginName` field. |
+| `opts?` | [`ToolkitOptions`](Interface.ToolkitOptions.md) | Optional toolkit scoping — `prefix`, `only`, `except`, `rename`. Same shape as the `.toolkit()` method. |
+
+## Returns
+
+`FromPluginSpread`
+
+## Example
+
+```ts
+import { analytics, createAgent, files, fromPlugin, tool } from "@databricks/appkit";
+
+const support = createAgent({
+ instructions: "You help customers.",
+ tools: {
+ ...fromPlugin(analytics),
+ ...fromPlugin(files, { only: ["uploads.read"] }),
+ get_weather: tool({ ... }),
+ },
+});
+```
diff --git a/docs/docs/api/appkit/Function.isFromPluginMarker.md b/docs/docs/api/appkit/Function.isFromPluginMarker.md
new file mode 100644
index 00000000..2ba9c752
--- /dev/null
+++ b/docs/docs/api/appkit/Function.isFromPluginMarker.md
@@ -0,0 +1,17 @@
+# Function: isFromPluginMarker()
+
+```ts
+function isFromPluginMarker(value: unknown): value is FromPluginMarker;
+```
+
+Type guard for [FromPluginMarker](Interface.FromPluginMarker.md).
+
+## Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `value` | `unknown` |
+
+## Returns
+
+`value is FromPluginMarker`
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/Function.isToolkitEntry.md b/docs/docs/api/appkit/Function.isToolkitEntry.md
new file mode 100644
index 00000000..892907a4
--- /dev/null
+++ b/docs/docs/api/appkit/Function.isToolkitEntry.md
@@ -0,0 +1,18 @@
+# Function: isToolkitEntry()
+
+```ts
+function isToolkitEntry(value: unknown): value is ToolkitEntry;
+```
+
+Type guard for `ToolkitEntry` — used by the agents plugin to differentiate
+toolkit references from inline tools in a mixed `tools` record.
+
+## Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `value` | `unknown` |
+
+## Returns
+
+`value is ToolkitEntry`
diff --git a/docs/docs/api/appkit/Function.loadAgentFromFile.md b/docs/docs/api/appkit/Function.loadAgentFromFile.md
new file mode 100644
index 00000000..3eab5346
--- /dev/null
+++ b/docs/docs/api/appkit/Function.loadAgentFromFile.md
@@ -0,0 +1,19 @@
+# Function: loadAgentFromFile()
+
+```ts
+function loadAgentFromFile(filePath: string, ctx: LoadContext): Promise;
+```
+
+Loads a single markdown agent file and resolves its frontmatter against
+registered plugin toolkits + ambient tool library.
+
+## Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `filePath` | `string` |
+| `ctx` | `LoadContext` |
+
+## Returns
+
+`Promise`\<[`AgentDefinition`](Interface.AgentDefinition.md)\>
diff --git a/docs/docs/api/appkit/Function.loadAgentsFromDir.md b/docs/docs/api/appkit/Function.loadAgentsFromDir.md
new file mode 100644
index 00000000..86665e17
--- /dev/null
+++ b/docs/docs/api/appkit/Function.loadAgentsFromDir.md
@@ -0,0 +1,20 @@
+# Function: loadAgentsFromDir()
+
+```ts
+function loadAgentsFromDir(dir: string, ctx: LoadContext): Promise;
+```
+
+Scans a directory for `*.md` files and produces an `AgentDefinition` record
+keyed by file-stem. Throws on frontmatter errors or unresolved references.
+Returns an empty map if the directory does not exist.
+
+## Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `dir` | `string` |
+| `ctx` | `LoadContext` |
+
+## Returns
+
+`Promise`\<`LoadResult`\>
diff --git a/docs/docs/api/appkit/Function.mcpServer.md b/docs/docs/api/appkit/Function.mcpServer.md
new file mode 100644
index 00000000..cafd4657
--- /dev/null
+++ b/docs/docs/api/appkit/Function.mcpServer.md
@@ -0,0 +1,26 @@
+# Function: mcpServer()
+
+```ts
+function mcpServer(name: string, url: string): CustomMcpServerTool;
+```
+
+Factory for declaring a custom MCP server tool.
+
+Replaces the verbose `{ type: "custom_mcp_server", custom_mcp_server: { app_name, app_url } }`
+wrapper with a concise positional call.
+
+Example:
+```ts
+mcpServer("my-app", "https://my-app.databricksapps.com/mcp")
+```
+
+## Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `name` | `string` |
+| `url` | `string` |
+
+## Returns
+
+`CustomMcpServerTool`
diff --git a/docs/docs/api/appkit/Function.runAgent.md b/docs/docs/api/appkit/Function.runAgent.md
new file mode 100644
index 00000000..4e1f8608
--- /dev/null
+++ b/docs/docs/api/appkit/Function.runAgent.md
@@ -0,0 +1,29 @@
+# Function: runAgent()
+
+```ts
+function runAgent(def: AgentDefinition, input: RunAgentInput): Promise;
+```
+
+Standalone agent execution without `createApp`. Resolves the adapter, binds
+inline tools, and drives the adapter's `run()` loop to completion.
+
+Limitations vs. running through the agents() plugin:
+- No OBO: there is no HTTP request, so plugin tools run as the service
+ principal (when they work at all).
+- Hosted tools (MCP) are not supported — they require a live MCP client
+ that only exists inside the agents plugin.
+- Sub-agents (`agents: { ... }` on the def) are executed as nested
+ `runAgent` calls with no shared thread state.
+- Plugin tools (`fromPlugin` markers or `ToolkitEntry` spreads) require
+ passing `plugins: [...]` via `RunAgentInput`.
+
+## Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `def` | [`AgentDefinition`](Interface.AgentDefinition.md) |
+| `input` | [`RunAgentInput`](Interface.RunAgentInput.md) |
+
+## Returns
+
+`Promise`\<[`RunAgentResult`](Interface.RunAgentResult.md)\>
diff --git a/docs/docs/api/appkit/Function.tool.md b/docs/docs/api/appkit/Function.tool.md
new file mode 100644
index 00000000..d6799cfd
--- /dev/null
+++ b/docs/docs/api/appkit/Function.tool.md
@@ -0,0 +1,29 @@
+# Function: tool()
+
+```ts
+function tool(config: ToolConfig): FunctionTool;
+```
+
+Factory for defining function tools with Zod schemas.
+
+- Generates JSON Schema (for the LLM) from the Zod schema via `z.toJSONSchema()`.
+- Infers the `execute` argument type from the schema.
+- Validates tool call arguments at runtime. On validation failure, returns
+ a formatted error string to the LLM instead of throwing, so the model
+ can self-correct on its next turn.
+
+## Type Parameters
+
+| Type Parameter |
+| ------ |
+| `S` *extends* `ZodType`\<`unknown`, `unknown`, `$ZodTypeInternals`\<`unknown`, `unknown`\>\> |
+
+## Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `config` | [`ToolConfig`](Interface.ToolConfig.md)\<`S`\> |
+
+## Returns
+
+[`FunctionTool`](Interface.FunctionTool.md)
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.AgentDefinition.md b/docs/docs/api/appkit/Interface.AgentDefinition.md
new file mode 100644
index 00000000..a3d7dc77
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.AgentDefinition.md
@@ -0,0 +1,82 @@
+# Interface: AgentDefinition
+
+## Properties
+
+### agents?
+
+```ts
+optional agents: Record;
+```
+
+Sub-agents, exposed as `agent-` tools on this agent.
+
+***
+
+### baseSystemPrompt?
+
+```ts
+optional baseSystemPrompt: BaseSystemPromptOption;
+```
+
+Override the plugin's baseSystemPrompt for this agent only.
+
+***
+
+### instructions
+
+```ts
+instructions: string;
+```
+
+System prompt body. For markdown-loaded agents this is the file body.
+
+***
+
+### maxSteps?
+
+```ts
+optional maxSteps: number;
+```
+
+***
+
+### maxTokens?
+
+```ts
+optional maxTokens: number;
+```
+
+***
+
+### model?
+
+```ts
+optional model:
+ | string
+ | AgentAdapter
+| Promise;
+```
+
+Model adapter (or endpoint-name string sugar for
+`DatabricksAdapter.fromServingEndpoint({ endpointName })`). Optional —
+falls back to the plugin's `defaultModel`.
+
+***
+
+### name?
+
+```ts
+optional name: string;
+```
+
+Filled in from the enclosing key when used in `agents: { foo: def }`.
+
+***
+
+### tools?
+
+```ts
+optional tools: AgentTools;
+```
+
+Per-agent tool record. Key is the LLM-visible tool-call name.
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.AgentsPluginConfig.md b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md
new file mode 100644
index 00000000..79af0562
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.AgentsPluginConfig.md
@@ -0,0 +1,132 @@
+# Interface: AgentsPluginConfig
+
+Base configuration interface for AppKit plugins
+
+## Extends
+
+- [`BasePluginConfig`](Interface.BasePluginConfig.md)
+
+## Indexable
+
+```ts
+[key: string]: unknown
+```
+
+## Properties
+
+### agents?
+
+```ts
+optional agents: Record;
+```
+
+Code-defined agents, merged with file-loaded ones (code wins on key collision).
+
+***
+
+### autoInheritTools?
+
+```ts
+optional autoInheritTools: boolean | AutoInheritToolsConfig;
+```
+
+Whether to auto-inherit every ToolProvider plugin's toolkit. Accepts a boolean shorthand.
+
+***
+
+### baseSystemPrompt?
+
+```ts
+optional baseSystemPrompt: BaseSystemPromptOption;
+```
+
+Customize or disable the AppKit base system prompt.
+
+***
+
+### defaultAgent?
+
+```ts
+optional defaultAgent: string;
+```
+
+Agent used when clients don't specify one. Defaults to the first-registered agent or the file with `default: true` frontmatter.
+
+***
+
+### defaultModel?
+
+```ts
+optional defaultModel:
+ | string
+ | AgentAdapter
+| Promise;
+```
+
+Default model for agents that don't specify their own (in code or frontmatter).
+
+***
+
+### dir?
+
+```ts
+optional dir: string | false;
+```
+
+Directory to scan for markdown agent files. Default `./config/agents`. Set to `false` to disable.
+
+***
+
+### host?
+
+```ts
+optional host: string;
+```
+
+#### Inherited from
+
+[`BasePluginConfig`](Interface.BasePluginConfig.md).[`host`](Interface.BasePluginConfig.md#host)
+
+***
+
+### name?
+
+```ts
+optional name: string;
+```
+
+#### Inherited from
+
+[`BasePluginConfig`](Interface.BasePluginConfig.md).[`name`](Interface.BasePluginConfig.md#name)
+
+***
+
+### telemetry?
+
+```ts
+optional telemetry: TelemetryOptions;
+```
+
+#### Inherited from
+
+[`BasePluginConfig`](Interface.BasePluginConfig.md).[`telemetry`](Interface.BasePluginConfig.md#telemetry)
+
+***
+
+### threadStore?
+
+```ts
+optional threadStore: ThreadStore;
+```
+
+Persistent thread store. Default: in-memory.
+
+***
+
+### tools?
+
+```ts
+optional tools: Record;
+```
+
+Ambient tool library. Keys may be referenced by markdown frontmatter via `tools: [key1, key2]`.
diff --git a/docs/docs/api/appkit/Interface.BasePluginConfig.md b/docs/docs/api/appkit/Interface.BasePluginConfig.md
index a7faffc6..130a61c1 100644
--- a/docs/docs/api/appkit/Interface.BasePluginConfig.md
+++ b/docs/docs/api/appkit/Interface.BasePluginConfig.md
@@ -2,6 +2,10 @@
Base configuration interface for AppKit plugins
+## Extended by
+
+- [`AgentsPluginConfig`](Interface.AgentsPluginConfig.md)
+
## Indexable
```ts
diff --git a/docs/docs/api/appkit/Interface.FromPluginMarker.md b/docs/docs/api/appkit/Interface.FromPluginMarker.md
new file mode 100644
index 00000000..1a1fedd3
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.FromPluginMarker.md
@@ -0,0 +1,32 @@
+# Interface: FromPluginMarker
+
+A lazy reference to a plugin's tools, produced by [fromPlugin](Function.fromPlugin.md) and
+resolved to concrete `ToolkitEntry`s at `AgentsPlugin.setup()` time.
+
+The marker is spread under a unique symbol key so multiple calls to
+`fromPlugin` (even for the same plugin) coexist in an `AgentDefinition.tools`
+record without colliding.
+
+## Properties
+
+### \[FROM\_PLUGIN\_MARKER\]
+
+```ts
+readonly [FROM_PLUGIN_MARKER]: true;
+```
+
+***
+
+### opts
+
+```ts
+readonly opts: ToolkitOptions | undefined;
+```
+
+***
+
+### pluginName
+
+```ts
+readonly pluginName: string;
+```
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/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.PromptContext.md b/docs/docs/api/appkit/Interface.PromptContext.md
new file mode 100644
index 00000000..e26ea167
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.PromptContext.md
@@ -0,0 +1,27 @@
+# Interface: PromptContext
+
+Context passed to `baseSystemPrompt` callbacks.
+
+## Properties
+
+### agentName
+
+```ts
+agentName: string;
+```
+
+***
+
+### pluginNames
+
+```ts
+pluginNames: string[];
+```
+
+***
+
+### toolNames
+
+```ts
+toolNames: string[];
+```
diff --git a/docs/docs/api/appkit/Interface.RunAgentInput.md b/docs/docs/api/appkit/Interface.RunAgentInput.md
new file mode 100644
index 00000000..c7fa4b02
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.RunAgentInput.md
@@ -0,0 +1,35 @@
+# Interface: RunAgentInput
+
+## Properties
+
+### messages
+
+```ts
+messages: string | Message[];
+```
+
+Seed messages for the run. Either a single user string or a full message list.
+
+***
+
+### plugins?
+
+```ts
+optional plugins: PluginData[];
+```
+
+Optional plugin list used to resolve `fromPlugin` markers in `def.tools`.
+Required when the def contains any `...fromPlugin(factory)` spreads;
+ignored otherwise. `runAgent` constructs a fresh instance per plugin
+and dispatches tool calls against it as the service principal (no
+OBO — there is no HTTP request in standalone mode).
+
+***
+
+### signal?
+
+```ts
+optional signal: AbortSignal;
+```
+
+Abort signal for cancellation.
diff --git a/docs/docs/api/appkit/Interface.RunAgentResult.md b/docs/docs/api/appkit/Interface.RunAgentResult.md
new file mode 100644
index 00000000..a9ba258d
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.RunAgentResult.md
@@ -0,0 +1,21 @@
+# Interface: RunAgentResult
+
+## Properties
+
+### events
+
+```ts
+events: AgentEvent[];
+```
+
+Every event the adapter yielded, in order. Useful for inspection/tests.
+
+***
+
+### text
+
+```ts
+text: string;
+```
+
+Aggregated text output from all `message_delta` events.
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.ToolConfig.md b/docs/docs/api/appkit/Interface.ToolConfig.md
new file mode 100644
index 00000000..48828a38
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.ToolConfig.md
@@ -0,0 +1,49 @@
+# Interface: ToolConfig\
+
+## Type Parameters
+
+| Type Parameter |
+| ------ |
+| `S` *extends* `z.ZodType` |
+
+## Properties
+
+### description?
+
+```ts
+optional description: string;
+```
+
+***
+
+### execute()
+
+```ts
+execute: (args: output) => string | Promise;
+```
+
+#### Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `args` | `output`\<`S`\> |
+
+#### Returns
+
+`string` \| `Promise`\<`string`\>
+
+***
+
+### name
+
+```ts
+name: string;
+```
+
+***
+
+### schema
+
+```ts
+schema: S;
+```
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/Interface.ToolkitEntry.md b/docs/docs/api/appkit/Interface.ToolkitEntry.md
new file mode 100644
index 00000000..699c07b0
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.ToolkitEntry.md
@@ -0,0 +1,46 @@
+# Interface: ToolkitEntry
+
+A tool reference produced by a plugin's `.toolkit()` call. The agents plugin
+recognizes the `__toolkitRef` brand and dispatches tool invocations through
+`PluginContext.executeTool(req, pluginName, localName, ...)`, preserving
+OBO (asUser) and telemetry spans.
+
+## Properties
+
+### \_\_toolkitRef
+
+```ts
+readonly __toolkitRef: true;
+```
+
+***
+
+### annotations?
+
+```ts
+optional annotations: ToolAnnotations;
+```
+
+***
+
+### def
+
+```ts
+def: AgentToolDefinition;
+```
+
+***
+
+### localName
+
+```ts
+localName: string;
+```
+
+***
+
+### pluginName
+
+```ts
+pluginName: string;
+```
diff --git a/docs/docs/api/appkit/Interface.ToolkitOptions.md b/docs/docs/api/appkit/Interface.ToolkitOptions.md
new file mode 100644
index 00000000..1beb22b0
--- /dev/null
+++ b/docs/docs/api/appkit/Interface.ToolkitOptions.md
@@ -0,0 +1,41 @@
+# Interface: ToolkitOptions
+
+## Properties
+
+### except?
+
+```ts
+optional except: string[];
+```
+
+Exclude tools whose local name matches one of these.
+
+***
+
+### only?
+
+```ts
+optional only: string[];
+```
+
+Only include tools whose local name matches one of these.
+
+***
+
+### prefix?
+
+```ts
+optional prefix: string;
+```
+
+Key prefix to prepend to each tool's local name. Defaults to `${pluginName}.`.
+
+***
+
+### rename?
+
+```ts
+optional rename: Record;
+```
+
+Remap specific local names to different keys (applied after prefix).
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/TypeAlias.AgentTool.md b/docs/docs/api/appkit/TypeAlias.AgentTool.md
new file mode 100644
index 00000000..e165cec6
--- /dev/null
+++ b/docs/docs/api/appkit/TypeAlias.AgentTool.md
@@ -0,0 +1,12 @@
+# Type Alias: AgentTool
+
+```ts
+type AgentTool =
+ | FunctionTool
+ | HostedTool
+ | ToolkitEntry;
+```
+
+Any tool an agent can invoke: inline function tools (`tool()`), hosted MCP
+tools (`mcpServer()` / raw hosted), or toolkit references from plugins
+(`analytics().toolkit()`).
diff --git a/docs/docs/api/appkit/TypeAlias.AgentTools.md b/docs/docs/api/appkit/TypeAlias.AgentTools.md
new file mode 100644
index 00000000..05b9ce61
--- /dev/null
+++ b/docs/docs/api/appkit/TypeAlias.AgentTools.md
@@ -0,0 +1,14 @@
+# Type Alias: AgentTools
+
+```ts
+type AgentTools = {
+[key: string]: AgentTool;
+} & {
+[key: symbol]: FromPluginMarker;
+};
+```
+
+Per-agent tool record. String keys map to inline tools, toolkit entries,
+hosted tools, etc. Symbol keys hold `FromPluginMarker` references produced
+by `fromPlugin(factory)` spreads — these are resolved at
+`AgentsPlugin.setup()` time against registered `ToolProvider` plugins.
diff --git a/docs/docs/api/appkit/TypeAlias.BaseSystemPromptOption.md b/docs/docs/api/appkit/TypeAlias.BaseSystemPromptOption.md
new file mode 100644
index 00000000..c5922661
--- /dev/null
+++ b/docs/docs/api/appkit/TypeAlias.BaseSystemPromptOption.md
@@ -0,0 +1,8 @@
+# Type Alias: BaseSystemPromptOption
+
+```ts
+type BaseSystemPromptOption =
+ | false
+ | string
+ | (ctx: PromptContext) => string;
+```
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/Variable.agents.md b/docs/docs/api/appkit/Variable.agents.md
new file mode 100644
index 00000000..d5bc7a09
--- /dev/null
+++ b/docs/docs/api/appkit/Variable.agents.md
@@ -0,0 +1,19 @@
+# Variable: agents
+
+```ts
+const agents: ToPlugin & NamedPluginFactory;
+```
+
+Plugin factory for the agents plugin. Reads `config/agents/*.md` by default,
+resolves toolkits/tools from registered plugins, exposes `appkit.agents.*`
+runtime API and mounts `/invocations`.
+
+## Example
+
+```ts
+import { agents, analytics, createApp, server } from "@databricks/appkit";
+
+await createApp({
+ plugins: [server(), analytics(), agents()],
+});
+```
diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md
index f5163db4..4abfedcb 100644
--- a/docs/docs/api/appkit/index.md
+++ b/docs/docs/api/appkit/index.md
@@ -30,31 +30,54 @@ plugin architecture, and React integration.
| Interface | Description |
| ------ | ------ |
+| [AgentAdapter](Interface.AgentAdapter.md) | - |
+| [AgentDefinition](Interface.AgentDefinition.md) | - |
+| [AgentInput](Interface.AgentInput.md) | - |
+| [AgentRunContext](Interface.AgentRunContext.md) | - |
+| [AgentsPluginConfig](Interface.AgentsPluginConfig.md) | Base configuration interface for AppKit plugins |
+| [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 |
| [EndpointConfig](Interface.EndpointConfig.md) | - |
+| [FromPluginMarker](Interface.FromPluginMarker.md) | A lazy reference to a plugin's tools, produced by [fromPlugin](Function.fromPlugin.md) and resolved to concrete `ToolkitEntry`s at `AgentsPlugin.setup()` time. |
+| [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 |
+| [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. |
+| [PromptContext](Interface.PromptContext.md) | Context passed to `baseSystemPrompt` callbacks. |
| [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 |
| [ResourceEntry](Interface.ResourceEntry.md) | Internal representation of a resource in the registry. Extends ResourceRequirement with resolution state and plugin ownership. |
| [ResourceFieldEntry](Interface.ResourceFieldEntry.md) | Defines a single field for a resource. Each field has its own environment variable and optional description. Single-value types use one key (e.g. id); multi-value types (database, secret) use multiple (e.g. instance_name, database_name or scope, key). |
| [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. |
+| [RunAgentInput](Interface.RunAgentInput.md) | - |
+| [RunAgentResult](Interface.RunAgentResult.md) | - |
| [ServingEndpointEntry](Interface.ServingEndpointEntry.md) | Shape of a single registry entry. |
| [ServingEndpointRegistry](Interface.ServingEndpointRegistry.md) | Registry interface for serving endpoint type generation. Empty by default — augmented by the Vite type generator's `.d.ts` output via module augmentation. When populated, provides autocomplete for alias names and typed request/response/chunk per endpoint. |
| [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) | - |
+| [ToolConfig](Interface.ToolConfig.md) | - |
+| [ToolkitEntry](Interface.ToolkitEntry.md) | A tool reference produced by a plugin's `.toolkit()` call. The agents plugin recognizes the `__toolkitRef` brand and dispatches tool invocations through `PluginContext.executeTool(req, pluginName, localName, ...)`, preserving OBO (asUser) and telemetry spans. |
+| [ToolkitOptions](Interface.ToolkitOptions.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) | - |
+| [AgentTool](TypeAlias.AgentTool.md) | Any tool an agent can invoke: inline function tools (`tool()`), hosted MCP tools (`mcpServer()` / raw hosted), or toolkit references from plugins (`analytics().toolkit()`). |
+| [AgentTools](TypeAlias.AgentTools.md) | Per-agent tool record. String keys map to inline tools, toolkit entries, hosted tools, etc. Symbol keys hold `FromPluginMarker` references produced by `fromPlugin(factory)` spreads — these are resolved at `AgentsPlugin.setup()` time against registered `ToolProvider` plugins. |
+| [BaseSystemPromptOption](TypeAlias.BaseSystemPromptOption.md) | - |
| [ConfigSchema](TypeAlias.ConfigSchema.md) | Configuration schema definition for plugin config. Re-exported from the standard JSON Schema Draft 7 types. |
| [ExecutionResult](TypeAlias.ExecutionResult.md) | Discriminated union for plugin execution results. |
+| [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. |
@@ -65,6 +88,7 @@ plugin architecture, and React integration.
| Variable | Description |
| ------ | ------ |
+| [agents](Variable.agents.md) | Plugin factory for the agents plugin. Reads `config/agents/*.md` by default, resolves toolkits/tools from registered plugins, exposes `appkit.agents.*` runtime API and mounts `/invocations`. |
| [sql](Variable.sql.md) | SQL helper namespace |
## Functions
@@ -73,10 +97,12 @@ plugin architecture, and React integration.
| ------ | ------ |
| [appKitServingTypesPlugin](Function.appKitServingTypesPlugin.md) | Vite plugin to generate TypeScript types for AppKit serving endpoints. Fetches OpenAPI schemas from Databricks and generates a .d.ts with ServingEndpointRegistry module augmentation. |
| [appKitTypesPlugin](Function.appKitTypesPlugin.md) | Vite plugin to generate types for AppKit queries. Calls generateFromEntryPoint under the hood. |
+| [createAgent](Function.createAgent.md) | Pure factory for agent definitions. Returns the passed-in definition after cycle-detecting the sub-agent graph. Accepts the full `AgentDefinition` shape and is safe to call at module top-level. |
| [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. |
| [extractServingEndpoints](Function.extractServingEndpoints.md) | Extract serving endpoint config from a server file by AST-parsing it. Looks for `serving({ endpoints: { alias: { env: "..." }, ... } })` calls and extracts the endpoint alias names and their environment variable mappings. |
| [findServerFile](Function.findServerFile.md) | Find the server entry file by checking candidate paths in order. |
+| [fromPlugin](Function.fromPlugin.md) | Reference a plugin's tools inside an `AgentDefinition.tools` record without naming the plugin instance. The returned spread-friendly object carries a symbol-keyed marker that the agents plugin resolves against registered `ToolProvider`s at setup time. |
| [generateDatabaseCredential](Function.generateDatabaseCredential.md) | Generate OAuth credentials for Postgres database connection using the proper Postgres API. |
| [getExecutionContext](Function.getExecutionContext.md) | Get the current execution context. |
| [getLakebaseOrmConfig](Function.getLakebaseOrmConfig.md) | Get Lakebase connection configuration for ORMs that don't accept pg.Pool directly. |
@@ -85,4 +111,13 @@ 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 |
+| [isFromPluginMarker](Function.isFromPluginMarker.md) | Type guard for [FromPluginMarker](Interface.FromPluginMarker.md). |
+| [isFunctionTool](Function.isFunctionTool.md) | - |
+| [isHostedTool](Function.isHostedTool.md) | - |
| [isSQLTypeMarker](Function.isSQLTypeMarker.md) | Type guard to check if a value is a SQL type marker |
+| [isToolkitEntry](Function.isToolkitEntry.md) | Type guard for `ToolkitEntry` — used by the agents plugin to differentiate toolkit references from inline tools in a mixed `tools` record. |
+| [loadAgentFromFile](Function.loadAgentFromFile.md) | Loads a single markdown agent file and resolves its frontmatter against registered plugin toolkits + ambient tool library. |
+| [loadAgentsFromDir](Function.loadAgentsFromDir.md) | Scans a directory for `*.md` files and produces an `AgentDefinition` record keyed by file-stem. Throws on frontmatter errors or unresolved references. Returns an empty map if the directory does not exist. |
+| [mcpServer](Function.mcpServer.md) | Factory for declaring a custom MCP server tool. |
+| [runAgent](Function.runAgent.md) | Standalone agent execution without `createApp`. Resolves the adapter, binds inline tools, and drives the adapter's `run()` loop to completion. |
+| [tool](Function.tool.md) | Factory for defining function tools with Zod schemas. |
diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts
index 720c78ea..cf001fcc 100644
--- a/docs/docs/api/appkit/typedoc-sidebar.ts
+++ b/docs/docs/api/appkit/typedoc-sidebar.ts
@@ -82,6 +82,36 @@ const typedocSidebar: SidebarsConfig = {
type: "category",
label: "Interfaces",
items: [
+ {
+ type: "doc",
+ id: "api/appkit/Interface.AgentAdapter",
+ label: "AgentAdapter"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Interface.AgentDefinition",
+ label: "AgentDefinition"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Interface.AgentInput",
+ label: "AgentInput"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Interface.AgentRunContext",
+ label: "AgentRunContext"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Interface.AgentsPluginConfig",
+ label: "AgentsPluginConfig"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Interface.AgentToolDefinition",
+ label: "AgentToolDefinition"
+ },
{
type: "doc",
id: "api/appkit/Interface.BasePluginConfig",
@@ -102,6 +132,16 @@ const typedocSidebar: SidebarsConfig = {
id: "api/appkit/Interface.EndpointConfig",
label: "EndpointConfig"
},
+ {
+ type: "doc",
+ id: "api/appkit/Interface.FromPluginMarker",
+ label: "FromPluginMarker"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Interface.FunctionTool",
+ label: "FunctionTool"
+ },
{
type: "doc",
id: "api/appkit/Interface.GenerateDatabaseCredentialRequest",
@@ -117,11 +157,21 @@ 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",
label: "PluginManifest"
},
+ {
+ type: "doc",
+ id: "api/appkit/Interface.PromptContext",
+ label: "PromptContext"
+ },
{
type: "doc",
id: "api/appkit/Interface.RequestedClaims",
@@ -147,6 +197,16 @@ const typedocSidebar: SidebarsConfig = {
id: "api/appkit/Interface.ResourceRequirement",
label: "ResourceRequirement"
},
+ {
+ type: "doc",
+ id: "api/appkit/Interface.RunAgentInput",
+ label: "RunAgentInput"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Interface.RunAgentResult",
+ label: "RunAgentResult"
+ },
{
type: "doc",
id: "api/appkit/Interface.ServingEndpointEntry",
@@ -167,6 +227,36 @@ 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.ToolConfig",
+ label: "ToolConfig"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Interface.ToolkitEntry",
+ label: "ToolkitEntry"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Interface.ToolkitOptions",
+ label: "ToolkitOptions"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Interface.ToolProvider",
+ label: "ToolProvider"
+ },
{
type: "doc",
id: "api/appkit/Interface.ValidationResult",
@@ -178,6 +268,26 @@ const typedocSidebar: SidebarsConfig = {
type: "category",
label: "Type Aliases",
items: [
+ {
+ type: "doc",
+ id: "api/appkit/TypeAlias.AgentEvent",
+ label: "AgentEvent"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/TypeAlias.AgentTool",
+ label: "AgentTool"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/TypeAlias.AgentTools",
+ label: "AgentTools"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/TypeAlias.BaseSystemPromptOption",
+ label: "BaseSystemPromptOption"
+ },
{
type: "doc",
id: "api/appkit/TypeAlias.ConfigSchema",
@@ -188,6 +298,11 @@ const typedocSidebar: SidebarsConfig = {
id: "api/appkit/TypeAlias.ExecutionResult",
label: "ExecutionResult"
},
+ {
+ type: "doc",
+ id: "api/appkit/TypeAlias.HostedTool",
+ label: "HostedTool"
+ },
{
type: "doc",
id: "api/appkit/TypeAlias.IAppRouter",
@@ -219,6 +334,11 @@ const typedocSidebar: SidebarsConfig = {
type: "category",
label: "Variables",
items: [
+ {
+ type: "doc",
+ id: "api/appkit/Variable.agents",
+ label: "agents"
+ },
{
type: "doc",
id: "api/appkit/Variable.sql",
@@ -240,6 +360,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",
@@ -260,6 +385,11 @@ const typedocSidebar: SidebarsConfig = {
id: "api/appkit/Function.findServerFile",
label: "findServerFile"
},
+ {
+ type: "doc",
+ id: "api/appkit/Function.fromPlugin",
+ label: "fromPlugin"
+ },
{
type: "doc",
id: "api/appkit/Function.generateDatabaseCredential",
@@ -300,10 +430,55 @@ const typedocSidebar: SidebarsConfig = {
id: "api/appkit/Function.getWorkspaceClient",
label: "getWorkspaceClient"
},
+ {
+ type: "doc",
+ id: "api/appkit/Function.isFromPluginMarker",
+ label: "isFromPluginMarker"
+ },
+ {
+ 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",
label: "isSQLTypeMarker"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Function.isToolkitEntry",
+ label: "isToolkitEntry"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Function.loadAgentFromFile",
+ label: "loadAgentFromFile"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Function.loadAgentsFromDir",
+ label: "loadAgentsFromDir"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Function.mcpServer",
+ label: "mcpServer"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Function.runAgent",
+ label: "runAgent"
+ },
+ {
+ type: "doc",
+ id: "api/appkit/Function.tool",
+ label: "tool"
}
]
}
diff --git a/docs/docs/plugins/agents.md b/docs/docs/plugins/agents.md
new file mode 100644
index 00000000..8eeabb5e
--- /dev/null
+++ b/docs/docs/plugins/agents.md
@@ -0,0 +1,270 @@
+# Agents
+
+The `agents` plugin turns a Databricks AppKit app into an AI-agent host. It loads agent definitions from markdown files (convention: `config/agents/*.md`), from TypeScript (`createAgent(def)`), or both, and exposes them at `POST /invocations` alongside routes for chat, thread management, and cancellation.
+
+This page covers the full lifecycle. For the hand-written primitives (`tool()`, `mcpServer()`), see [tools](./server.md).
+
+## Install
+
+`agents` is a regular plugin. Add it to `plugins[]` alongside `server()` and any ToolProvider plugins whose tools you want agents to reach.
+
+```ts
+import { agents, analytics, createApp, files, server } from "@databricks/appkit";
+
+await createApp({
+ plugins: [server(), analytics(), files(), agents()],
+});
+```
+
+That alone gives you a live HTTP server with `POST /invocations` wired to a markdown-driven agent.
+
+## Level 1: drop a markdown file
+
+```
+my-app/
+ server.ts
+ config/agents/
+ assistant.md
+```
+
+```md
+---
+endpoint: databricks-claude-sonnet-4-5
+default: true
+---
+
+You are a helpful data assistant running on Databricks.
+
+Use the available tools to query data, browse files, and help users.
+```
+
+On startup the plugin:
+
+1. Discovers the file at `./config/agents/assistant.md`.
+2. Parses the YAML frontmatter and markdown body as the agent's `instructions`.
+3. Resolves the adapter from `endpoint` (or falls back to `DATABRICKS_AGENT_ENDPOINT`).
+4. Auto-inherits every registered ToolProvider plugin's tools (`analytics.*`, `files.*`, …).
+5. Mounts the agent at the default name (`assistant`).
+
+Requests land at `POST /invocations` with an OpenAI Responses-compatible body. Every tool call runs through `asUser(req)` so SQL executes as the requesting user, file access respects Unity Catalog ACLs, and telemetry spans are created automatically.
+
+## Level 2: scope tools in frontmatter
+
+```md
+---
+endpoint: databricks-claude-sonnet-4-5
+toolkits:
+ - analytics # all analytics.* tools
+ - files: [uploads.read, uploads.list] # only these files tools
+ - genie: { except: [getConversation] } # everything but getConversation
+tools: [get_weather] # ambient tool declared in code
+default: true
+---
+
+You are a read-only data analyst.
+```
+
+When any `toolkits:` or `tools:` is declared the auto-inherit default is turned off — the agent sees exactly the listed tools. Ambient tools (`tools: [get_weather]`) are looked up in the `agents({ tools: { ... } })` config.
+
+## Level 3: code-defined agents
+
+```ts
+import {
+ agents,
+ analytics,
+ createAgent,
+ createApp,
+ files,
+ fromPlugin,
+ server,
+ tool,
+} from "@databricks/appkit";
+import { z } from "zod";
+
+const support = createAgent({
+ instructions: "You help customers with data and files.",
+ model: "databricks-claude-sonnet-4-5", // string sugar
+ tools: {
+ ...fromPlugin(analytics), // all analytics tools
+ ...fromPlugin(files, { only: ["uploads.read"] }), // filtered subset
+ get_weather: tool({
+ name: "get_weather",
+ description: "Weather",
+ schema: z.object({ city: z.string() }),
+ execute: async ({ city }) => `Sunny in ${city}`,
+ }),
+ },
+});
+
+await createApp({
+ plugins: [server(), analytics(), files(), agents({ agents: { support } })],
+});
+```
+
+Code-defined agents start with no tools by default. `fromPlugin(factory)` is the primary way to pull in a plugin's tools — it returns a spread-friendly marker that the agents plugin resolves against registered `ToolProvider`s at setup time. No intermediate variable, no duplicate `plugins: [analyticsP, filesP, ...]` dance: you write the factory reference once inside `fromPlugin` and again in `plugins: [...]`.
+
+The asymmetry (file: auto-inherit, code: strict) matches the personas: prompt authors want zero ceremony, engineers want no surprises.
+
+### Scoping tools in code
+
+`fromPlugin(factory, opts?)` accepts the same `ToolkitOptions` as markdown frontmatter:
+
+| Option | Example | Meaning |
+|---|---|---|
+| `only` | `{ only: ["query"] }` | Allowlist of local tool names |
+| `except` | `{ except: ["legacy"] }` | Denylist of local tool names |
+| `prefix` | `{ prefix: "" }` | Drop the `${pluginName}.` prefix |
+| `rename` | `{ rename: { query: "q" } }` | Remap specific local names |
+
+For plugins that don't expose a `.toolkit()` method (e.g., third-party `ToolProvider` plugins authored with plain `toPlugin`), `fromPlugin` falls back to walking `getAgentTools()` and synthesizing namespaced keys (`${pluginName}.${localName}`). The fallback respects `only` / `except` / `rename` / `prefix` the same way.
+
+If a referenced plugin is not registered in `createApp({ plugins })`, the agents plugin throws at setup with an `Available: …` listing so you can fix the wiring before the first request.
+
+## Level 4: sub-agents
+
+```ts
+const researcher = createAgent({
+ instructions: "Research the question. Return concise bullets.",
+ model: "databricks-claude-sonnet-4-5",
+ tools: { search: tool({ /* ... */ }) },
+});
+
+const writer = createAgent({
+ instructions: "Draft prose from notes.",
+ model: "databricks-claude-sonnet-4-5",
+});
+
+const supervisor = createAgent({
+ instructions: "Coordinate researcher and writer.",
+ model: "databricks-claude-sonnet-4-5",
+ agents: { researcher, writer }, // exposed as agent-researcher, agent-writer
+});
+
+await createApp({
+ plugins: [
+ server(),
+ agents({ agents: { supervisor, researcher, writer } }),
+ ],
+});
+```
+
+Each key in `agents: {...}` on an `AgentDefinition` becomes an `agent-` tool on the parent. When invoked, the agents plugin runs the child's adapter with a fresh message list (no shared thread state) and returns the aggregated text. Cycles are rejected at load time.
+
+## Level 5: standalone (no `createApp`)
+
+```ts
+import { createAgent, runAgent, tool } from "@databricks/appkit";
+import { z } from "zod";
+
+const classifier = createAgent({
+ instructions: "Classify tickets: billing | bug | feature.",
+ model: "databricks-claude-sonnet-4-5",
+ tools: {
+ lookup_account: tool({ /* ... */ }),
+ },
+});
+
+for (const ticket of tickets) {
+ const result = await runAgent(classifier, {
+ messages: [{ role: "user", content: ticket.body }],
+ });
+ await persistClassification(ticket.id, result.text);
+}
+```
+
+`runAgent` drives the adapter without `createApp` or HTTP. Inline `tool()` calls work standalone as shown above. To use plugin tools in standalone mode, pass the plugin factories through `RunAgentInput.plugins` — `runAgent` will resolve any `fromPlugin` markers in the def against that list:
+
+```ts
+import { analytics, createAgent, fromPlugin, runAgent } from "@databricks/appkit";
+
+const classifier = createAgent({
+ instructions: "Classify tickets. Use analytics.query for historical data.",
+ model: "databricks-claude-sonnet-4-5",
+ tools: { ...fromPlugin(analytics) },
+});
+
+const result = await runAgent(classifier, {
+ messages: "is ticket 42 a duplicate?",
+ plugins: [analytics()],
+});
+```
+
+Hosted tools (MCP) are still `agents()`-only since they require the live MCP client. Plugin tool dispatch in standalone mode runs as the service principal (no OBO) since there is no HTTP request.
+
+## Configuration reference
+
+```ts
+agents({
+ dir?: string | false, // "./config/agents" default; false disables
+ agents?: Record,
+ defaultAgent?: string,
+ defaultModel?: AgentAdapter | Promise | string,
+ tools?: Record,
+ autoInheritTools?: boolean | { file?: boolean, code?: boolean },
+ threadStore?: ThreadStore, // default in-memory
+ baseSystemPrompt?: false | string | (ctx: PromptContext) => string,
+ mcp?: {
+ trustedHosts?: string[], // extra hostnames allowed for custom MCP URLs
+ allowLocalhost?: boolean, // default: NODE_ENV !== "production"
+ },
+})
+```
+
+`autoInheritTools` defaults to `{ file: true, code: false }`. Boolean shorthand applies to both.
+
+### MCP host policy
+
+AppKit applies a zero-trust policy to every MCP URL used as a hosted tool. By default only **same-origin Databricks workspace URLs** (matching the resolved `DATABRICKS_HOST`) may be reached. Every other host must be explicitly allowlisted via `mcp.trustedHosts`, and workspace credentials (service-principal and on-behalf-of user tokens) are **never** forwarded to those hosts.
+
+```ts
+agents({
+ agents: {
+ support: createAgent({
+ instructions: "…",
+ tools: {
+ "mcp.internal": mcpServer("internal", "https://mcp.corp.internal/mcp"),
+ },
+ }),
+ },
+ mcp: {
+ trustedHosts: ["mcp.corp.internal"],
+ },
+});
+```
+
+The policy enforces four rules at MCP `connect()` time, before any byte is sent:
+
+1. Only `http` and `https` URLs are accepted.
+2. Plaintext `http://` is rejected for everything except `localhost` when `allowLocalhost` is true (default in development, off in production).
+3. The destination hostname must match the workspace host, equal `localhost` (if permitted), or appear in `trustedHosts`.
+4. The resolved DNS address must not fall in loopback, RFC1918, CGNAT (100.64.0.0/10), link-local (169.254.0.0/16 — covers cloud metadata services), ULA, or multicast ranges.
+
+`Authorization` headers carrying workspace credentials are scoped to same-origin workspace URLs. A `mcpServer(name, url)` pointing at a trusted external host must authenticate itself (for example, a custom token baked into `url`).
+
+## Runtime API
+
+After `createApp`, the plugin exposes:
+
+```ts
+appkit.agents.list(); // => ["support", "researcher", ...]
+appkit.agents.get("support"); // => RegisteredAgent | null
+appkit.agents.getDefault(); // => "support"
+appkit.agents.register(name, def); // dynamic registration
+appkit.agents.reload(); // re-scan the directory
+appkit.agents.getThreads(userId); // list user's threads
+```
+
+## Frontmatter schema
+
+| Key | Type | Notes |
+|---|---|---|
+| `endpoint` | string | Model serving endpoint name. Shortcut for `model`. |
+| `model` | string | Same as `endpoint`; either works. |
+| `toolkits` | array of string or `{ name: options }` | Spread plugin toolkits. Supports `only`, `except`, `rename`, `prefix`. |
+| `tools` | array of string | Keys into `agents({ tools: {...} })`. |
+| `default` | boolean | First file with `default: true` becomes the default agent. |
+| `maxSteps` | number | Adapter max-step hint. |
+| `maxTokens` | number | Adapter max-token hint. |
+| `baseSystemPrompt` | false \| string | Per-agent override. `false` disables the AppKit base prompt. |
+
+Unknown keys are logged and ignored. Invalid YAML and missing plugin/tool references throw at boot.
diff --git a/packages/appkit/package.json b/packages/appkit/package.json
index 27a14e66..49d6c516 100644
--- a/packages/appkit/package.json
+++ b/packages/appkit/package.json
@@ -83,6 +83,7 @@
"@types/semver": "7.7.1",
"dotenv": "16.6.1",
"express": "4.22.0",
+ "js-yaml": "^4.1.1",
"obug": "2.1.1",
"pg": "8.18.0",
"picocolors": "1.1.1",
@@ -108,6 +109,7 @@
"@ai-sdk/openai": "4.0.0-beta.27",
"@langchain/core": "^1.1.39",
"@types/express": "4.17.25",
+ "@types/js-yaml": "^4.0.9",
"@types/json-schema": "7.0.15",
"@types/pg": "8.16.0",
"@types/ws": "8.18.1",
diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts
index a2cba994..a0c2e566 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,
});
}
}
@@ -70,8 +78,20 @@ export class AppKit {
};
const pluginInstance = new Plugin(baseConfig);
+ if (typeof pluginInstance.attachContext === "function") {
+ pluginInstance.attachContext({
+ context: this.#context,
+ telemetryConfig: baseConfig.telemetry,
+ });
+ }
+
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 +219,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/create-agent-def.ts b/packages/appkit/src/core/create-agent-def.ts
new file mode 100644
index 00000000..3e93371d
--- /dev/null
+++ b/packages/appkit/src/core/create-agent-def.ts
@@ -0,0 +1,53 @@
+import { ConfigurationError } from "../errors";
+import type { AgentDefinition } from "../plugins/agents/types";
+
+/**
+ * Pure factory for agent definitions. Returns the passed-in definition after
+ * cycle-detecting the sub-agent graph. Accepts the full `AgentDefinition` shape
+ * and is safe to call at module top-level.
+ *
+ * The returned value is a plain `AgentDefinition` — no adapter construction,
+ * no side effects. Register it with `agents({ agents: { name: def } })` or run
+ * it standalone via `runAgent(def, input)`.
+ *
+ * @example
+ * ```ts
+ * const support = createAgent({
+ * instructions: "You help customers.",
+ * model: "databricks-claude-sonnet-4-5",
+ * tools: {
+ * get_weather: tool({ ... }),
+ * },
+ * });
+ * ```
+ */
+export function createAgent(def: AgentDefinition): AgentDefinition {
+ detectCycles(def);
+ return def;
+}
+
+/**
+ * Walks the `agents: { ... }` sub-agent tree via DFS and throws if a cycle is
+ * found. Cycles would cause infinite recursion at tool-invocation time.
+ */
+function detectCycles(def: AgentDefinition): void {
+ const visiting = new Set();
+ const visited = new Set();
+
+ const walk = (current: AgentDefinition, path: string[]): void => {
+ if (visited.has(current)) return;
+ if (visiting.has(current)) {
+ throw new ConfigurationError(
+ `Agent sub-agent cycle detected: ${path.join(" -> ")}`,
+ );
+ }
+ visiting.add(current);
+ for (const [childKey, child] of Object.entries(current.agents ?? {})) {
+ walk(child, [...path, childKey]);
+ }
+ visiting.delete(current);
+ visited.add(current);
+ };
+
+ walk(def, [def.name ?? "(root)"]);
+}
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/run-agent.ts b/packages/appkit/src/core/run-agent.ts
new file mode 100644
index 00000000..6bbed55f
--- /dev/null
+++ b/packages/appkit/src/core/run-agent.ts
@@ -0,0 +1,353 @@
+import { randomUUID } from "node:crypto";
+import type {
+ AgentAdapter,
+ AgentEvent,
+ AgentToolDefinition,
+ Message,
+ PluginConstructor,
+ PluginData,
+ ToolProvider,
+} from "shared";
+import { isFromPluginMarker } from "../plugins/agents/from-plugin";
+import { resolveToolkitFromProvider } from "../plugins/agents/toolkit-resolver";
+import {
+ type FunctionTool,
+ functionToolToDefinition,
+ isFunctionTool,
+} from "../plugins/agents/tools/function-tool";
+import { isHostedTool } from "../plugins/agents/tools/hosted-tools";
+import type {
+ AgentDefinition,
+ AgentTool,
+ ToolkitEntry,
+} from "../plugins/agents/types";
+import { isToolkitEntry } from "../plugins/agents/types";
+
+export interface RunAgentInput {
+ /** Seed messages for the run. Either a single user string or a full message list. */
+ messages: string | Message[];
+ /** Abort signal for cancellation. */
+ signal?: AbortSignal;
+ /**
+ * Optional plugin list used to resolve `fromPlugin` markers in `def.tools`.
+ * Required when the def contains any `...fromPlugin(factory)` spreads;
+ * ignored otherwise. `runAgent` constructs a fresh instance per plugin
+ * and dispatches tool calls against it as the service principal (no
+ * OBO — there is no HTTP request in standalone mode).
+ */
+ plugins?: PluginData[];
+}
+
+export interface RunAgentResult {
+ /** Aggregated text output from all `message_delta` events. */
+ text: string;
+ /** Every event the adapter yielded, in order. Useful for inspection/tests. */
+ events: AgentEvent[];
+}
+
+/**
+ * Standalone agent execution without `createApp`. Resolves the adapter, binds
+ * inline tools, and drives the adapter's `run()` loop to completion.
+ *
+ * Limitations vs. running through the agents() plugin:
+ * - No OBO: there is no HTTP request, so plugin tools run as the service
+ * principal (when they work at all).
+ * - Hosted tools (MCP) are not supported — they require a live MCP client
+ * that only exists inside the agents plugin.
+ * - Sub-agents (`agents: { ... }` on the def) are executed as nested
+ * `runAgent` calls with no shared thread state.
+ * - Plugin tools (`fromPlugin` markers or `ToolkitEntry` spreads) require
+ * passing `plugins: [...]` via `RunAgentInput`.
+ */
+export async function runAgent(
+ def: AgentDefinition,
+ input: RunAgentInput,
+): Promise {
+ const adapter = await resolveAdapter(def);
+ const messages = normalizeMessages(input.messages, def.instructions);
+ const toolIndex = buildStandaloneToolIndex(def, input.plugins ?? []);
+ const tools = Array.from(toolIndex.values()).map((e) => e.def);
+
+ const signal = input.signal;
+
+ const executeTool = async (name: string, args: unknown): Promise => {
+ const entry = toolIndex.get(name);
+ if (!entry) throw new Error(`Unknown tool: ${name}`);
+ if (entry.kind === "function") {
+ return entry.tool.execute(args as Record);
+ }
+ if (entry.kind === "toolkit") {
+ return entry.provider.executeAgentTool(
+ entry.localName,
+ args as Record,
+ signal,
+ );
+ }
+ if (entry.kind === "subagent") {
+ const subInput: RunAgentInput = {
+ messages:
+ typeof args === "object" &&
+ args !== null &&
+ typeof (args as { input?: unknown }).input === "string"
+ ? (args as { input: string }).input
+ : JSON.stringify(args),
+ signal,
+ plugins: input.plugins,
+ };
+ const res = await runAgent(entry.agentDef, subInput);
+ return res.text;
+ }
+ throw new Error(
+ `runAgent: tool "${name}" is a ${entry.kind} tool. ` +
+ "Hosted/MCP tools are only usable via createApp({ plugins: [..., agents(...)] }).",
+ );
+ };
+
+ const events: AgentEvent[] = [];
+ let text = "";
+
+ const stream = adapter.run(
+ {
+ messages,
+ tools,
+ threadId: randomUUID(),
+ signal,
+ },
+ { executeTool, signal },
+ );
+
+ for await (const event of stream) {
+ if (signal?.aborted) break;
+ events.push(event);
+ if (event.type === "message_delta") {
+ text += event.content;
+ } else if (event.type === "message") {
+ text = event.content;
+ }
+ }
+
+ return { text, events };
+}
+
+async function resolveAdapter(def: AgentDefinition): Promise {
+ const { model } = def;
+ if (!model) {
+ const { DatabricksAdapter } = await import("../agents/databricks");
+ return DatabricksAdapter.fromModelServing();
+ }
+ if (typeof model === "string") {
+ const { DatabricksAdapter } = await import("../agents/databricks");
+ return DatabricksAdapter.fromModelServing(model);
+ }
+ return await model;
+}
+
+function normalizeMessages(
+ input: string | Message[],
+ instructions: string,
+): Message[] {
+ const systemMessage: Message = {
+ id: "system",
+ role: "system",
+ content: instructions,
+ createdAt: new Date(),
+ };
+ if (typeof input === "string") {
+ return [
+ systemMessage,
+ {
+ id: randomUUID(),
+ role: "user",
+ content: input,
+ createdAt: new Date(),
+ },
+ ];
+ }
+ return [systemMessage, ...input];
+}
+
+type StandaloneEntry =
+ | {
+ kind: "function";
+ def: AgentToolDefinition;
+ tool: FunctionTool;
+ }
+ | {
+ kind: "subagent";
+ def: AgentToolDefinition;
+ agentDef: AgentDefinition;
+ }
+ | {
+ kind: "toolkit";
+ def: AgentToolDefinition;
+ provider: ToolProvider;
+ pluginName: string;
+ localName: string;
+ }
+ | {
+ kind: "hosted";
+ def: AgentToolDefinition;
+ };
+
+/**
+ * Resolves `def.tools` (string-keyed entries + symbol-keyed `fromPlugin`
+ * markers) and `def.agents` (sub-agents) into a flat dispatch index.
+ * Symbol-keyed markers are resolved against `plugins`; missing references
+ * throw with an `Available: …` listing.
+ */
+function buildStandaloneToolIndex(
+ def: AgentDefinition,
+ plugins: PluginData[],
+): Map {
+ const index = new Map();
+ const tools = def.tools;
+
+ const symbolKeys = tools ? Object.getOwnPropertySymbols(tools) : [];
+ if (symbolKeys.length > 0) {
+ const providerCache = new Map();
+ for (const sym of symbolKeys) {
+ const marker = (tools as Record)[sym];
+ if (!isFromPluginMarker(marker)) continue;
+
+ const provider = resolveStandaloneProvider(
+ marker.pluginName,
+ plugins,
+ providerCache,
+ );
+ const entries = resolveToolkitFromProvider(
+ marker.pluginName,
+ provider,
+ marker.opts,
+ );
+ for (const [key, entry] of Object.entries(entries)) {
+ index.set(key, {
+ kind: "toolkit",
+ provider,
+ pluginName: entry.pluginName,
+ localName: entry.localName,
+ def: { ...entry.def, name: key },
+ });
+ }
+ }
+ }
+
+ if (tools) {
+ for (const [key, tool] of Object.entries(tools)) {
+ index.set(key, classifyTool(key, tool));
+ }
+ }
+
+ for (const [childKey, child] of Object.entries(def.agents ?? {})) {
+ const toolName = `agent-${childKey}`;
+ index.set(toolName, {
+ kind: "subagent",
+ agentDef: { ...child, name: child.name ?? childKey },
+ def: {
+ name: toolName,
+ description:
+ child.instructions.slice(0, 120) ||
+ `Delegate to the ${childKey} sub-agent`,
+ parameters: {
+ type: "object",
+ properties: {
+ input: {
+ type: "string",
+ description: "Message to send to the sub-agent.",
+ },
+ },
+ required: ["input"],
+ },
+ },
+ });
+ }
+
+ return index;
+}
+
+function classifyTool(key: string, tool: AgentTool): StandaloneEntry {
+ if (isToolkitEntry(tool)) {
+ return toolkitEntryToStandalone(key, tool);
+ }
+ if (isFunctionTool(tool)) {
+ return {
+ kind: "function",
+ tool,
+ def: { ...functionToolToDefinition(tool), name: key },
+ };
+ }
+ if (isHostedTool(tool)) {
+ return {
+ kind: "hosted",
+ def: {
+ name: key,
+ description: `Hosted tool: ${tool.type}`,
+ parameters: { type: "object", properties: {} },
+ },
+ };
+ }
+ throw new Error(`runAgent: unrecognized tool shape at key "${key}"`);
+}
+
+/**
+ * Pre-`fromPlugin` code could reach a `ToolkitEntry` by calling
+ * `.toolkit()` at module scope (which requires an instance). Those entries
+ * still flow through `def.tools` but without a provider we can dispatch
+ * against — runAgent cannot execute them and errors clearly.
+ */
+function toolkitEntryToStandalone(
+ key: string,
+ entry: ToolkitEntry,
+): StandaloneEntry {
+ const def: AgentToolDefinition = { ...entry.def, name: key };
+ return {
+ kind: "hosted",
+ def: {
+ ...def,
+ description:
+ `${def.description ?? ""} ` +
+ `[runAgent: this ToolkitEntry refers to plugin '${entry.pluginName}' but ` +
+ "runAgent cannot dispatch it without the plugin instance. Pass the " +
+ "plugin via plugins: [...] and use fromPlugin(factory) instead of " +
+ ".toolkit() spreads.]".trim(),
+ },
+ };
+}
+
+function resolveStandaloneProvider(
+ pluginName: string,
+ plugins: PluginData[],
+ cache: Map,
+): ToolProvider {
+ const cached = cache.get(pluginName);
+ if (cached) return cached;
+
+ const match = plugins.find((p) => p.name === pluginName);
+ if (!match) {
+ const available = plugins.map((p) => p.name).join(", ") || "(none)";
+ throw new Error(
+ `runAgent: agent references plugin '${pluginName}' via fromPlugin(), but ` +
+ "that plugin is missing from RunAgentInput.plugins. " +
+ `Available: ${available}.`,
+ );
+ }
+
+ const instance = new match.plugin({
+ ...(match.config ?? {}),
+ name: pluginName,
+ });
+ const provider = instance as unknown as ToolProvider;
+ if (
+ typeof (provider as { getAgentTools?: unknown }).getAgentTools !==
+ "function" ||
+ typeof (provider as { executeAgentTool?: unknown }).executeAgentTool !==
+ "function"
+ ) {
+ throw new Error(
+ `runAgent: plugin '${pluginName}' is not a ToolProvider ` +
+ "(missing getAgentTools/executeAgentTool). Only ToolProvider plugins " +
+ "are supported via fromPlugin() in runAgent.",
+ );
+ }
+ cache.set(pluginName, provider);
+ return provider;
+}
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/index.ts b/packages/appkit/src/index.ts
index 955bfde6..6e643cc8 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";
@@ -34,6 +43,12 @@ export {
} from "./connectors/lakebase";
export { getExecutionContext } from "./context";
export { createApp } from "./core";
+export { createAgent } from "./core/create-agent-def";
+export {
+ type RunAgentInput,
+ type RunAgentResult,
+ runAgent,
+} from "./core/run-agent";
// Errors
export {
AppKitError,
@@ -54,6 +69,32 @@ export {
toPlugin,
} from "./plugin";
export { analytics, files, genie, lakebase, server, serving } from "./plugins";
+export {
+ type AgentDefinition,
+ type AgentsPluginConfig,
+ type AgentTool,
+ type AgentTools,
+ agents,
+ type BaseSystemPromptOption,
+ type FromPluginMarker,
+ fromPlugin,
+ isFromPluginMarker,
+ isToolkitEntry,
+ loadAgentFromFile,
+ loadAgentsFromDir,
+ type PromptContext,
+ type ToolkitEntry,
+ type ToolkitOptions,
+} from "./plugins/agents";
+export {
+ type FunctionTool,
+ type HostedTool,
+ isFunctionTool,
+ isHostedTool,
+ mcpServer,
+ type ToolConfig,
+ tool,
+} from "./plugins/agents/tools";
export type {
EndpointConfig,
ServingEndpointEntry,
diff --git a/packages/appkit/src/plugin/index.ts b/packages/appkit/src/plugin/index.ts
index 93765219..46a4eb94 100644
--- a/packages/appkit/src/plugin/index.ts
+++ b/packages/appkit/src/plugin/index.ts
@@ -1,4 +1,4 @@
export type { ToPlugin } from "shared";
export type { ExecutionResult } from "./execution-result";
export { Plugin } from "./plugin";
-export { toPlugin } from "./to-plugin";
+export { type NamedPluginFactory, toPlugin } from "./to-plugin";
diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts
index 5173cb61..4c9a0e64 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 { AppKitError, AuthenticationError } from "../errors";
import { createLogger } from "../logging/logger";
import { StreamManager } from "../stream";
@@ -163,11 +164,12 @@ export abstract class Plugin<
> implements BasePlugin
{
protected isReady = false;
- protected cache: CacheManager;
+ protected cache!: CacheManager;
protected app: AppManager;
protected devFileReader: DevFileReader;
protected streamManager: StreamManager;
- protected telemetry: ITelemetry;
+ protected telemetry!: ITelemetry;
+ protected context?: PluginContext;
/** Registered endpoints for this plugin */
private registeredEndpoints: PluginEndpointMap = {};
@@ -193,12 +195,58 @@ export abstract class Plugin<
config.name ??
(this.constructor as { manifest?: { name: string } }).manifest?.name ??
"plugin";
- this.telemetry = TelemetryManager.getProvider(this.name, config.telemetry);
this.streamManager = new StreamManager();
- this.cache = CacheManager.getInstanceSync();
this.app = new AppManager();
this.devFileReader = DevFileReader.getInstance();
+ this.context = (config as Record).context as
+ | PluginContext
+ | undefined;
+
+ // Eagerly bind telemetry + cache if the core services have already been
+ // initialized (normal createApp path, or tests that mock CacheManager).
+ // If they haven't, we leave these undefined and rely on `attachContext`
+ // being called later — this lets factories eagerly construct plugin
+ // instances at module top-level before `createApp` has run.
+ this.tryAttachContext();
+ }
+
+ private tryAttachContext(): void {
+ try {
+ this.cache = CacheManager.getInstanceSync();
+ } catch {
+ return;
+ }
+ this.telemetry = TelemetryManager.getProvider(
+ this.name,
+ this.config.telemetry,
+ );
+ this.isReady = true;
+ }
+ /**
+ * Binds runtime dependencies (telemetry provider, cache, plugin context) to
+ * this plugin. Called by `AppKit._createApp` after construction and before
+ * `setup()`. Idempotent: safe to call if the constructor already bound them
+ * eagerly. Kept separate so factories can eagerly construct plugin instances
+ * without running this before `TelemetryManager.initialize()` /
+ * `CacheManager.getInstance()` have run.
+ */
+ attachContext(
+ deps: {
+ context?: unknown;
+ telemetryConfig?: BasePluginConfig["telemetry"];
+ } = {},
+ ): void {
+ if (!this.cache) {
+ this.cache = CacheManager.getInstanceSync();
+ }
+ this.telemetry = TelemetryManager.getProvider(
+ this.name,
+ deps.telemetryConfig ?? this.config.telemetry,
+ );
+ if (deps.context !== undefined) {
+ this.context = deps.context as PluginContext;
+ }
this.isReady = true;
}
diff --git a/packages/appkit/src/plugin/to-plugin.ts b/packages/appkit/src/plugin/to-plugin.ts
index 77725027..c882f300 100644
--- a/packages/appkit/src/plugin/to-plugin.ts
+++ b/packages/appkit/src/plugin/to-plugin.ts
@@ -1,19 +1,41 @@
import type { PluginConstructor, PluginData, ToPlugin } from "shared";
/**
- * Wraps a plugin class so it can be passed to createApp with optional config.
- * Infers config type from the constructor and plugin name from the static `name` property.
+ * Factory function produced by {@link toPlugin}. Carries a static
+ * `pluginName` field so tooling (e.g. `fromPlugin`) can identify which
+ * plugin a factory references without constructing an instance.
+ */
+export type NamedPluginFactory = {
+ readonly pluginName: Name;
+};
+
+/**
+ * Wraps a plugin class so it can be passed to `createApp` with optional
+ * config. Infers the config type from the constructor and the plugin name
+ * from the static `manifest.name` property, and stamps `pluginName` onto
+ * the returned factory function so `fromPlugin` can identify the plugin
+ * without needing to construct it.
*
* @internal
*/
export function toPlugin(
plugin: T,
-): ToPlugin[0], T["manifest"]["name"]> {
+): ToPlugin[0], T["manifest"]["name"]> &
+ NamedPluginFactory {
type Config = ConstructorParameters[0];
type Name = T["manifest"]["name"];
- return (config: Config = {} as Config): PluginData => ({
+ const pluginName = plugin.manifest.name as Name;
+ const factory = (
+ config: Config = {} as Config,
+ ): PluginData => ({
plugin: plugin as T,
config: config as Config,
- name: plugin.manifest.name as Name,
+ name: pluginName,
+ });
+ Object.defineProperty(factory, "pluginName", {
+ value: pluginName,
+ writable: false,
+ enumerable: true,
});
+ return factory as ToPlugin & NamedPluginFactory;
}
diff --git a/packages/appkit/src/plugins/agents/agents.ts b/packages/appkit/src/plugins/agents/agents.ts
new file mode 100644
index 00000000..a4612252
--- /dev/null
+++ b/packages/appkit/src/plugins/agents/agents.ts
@@ -0,0 +1,1030 @@
+import { randomUUID } from "node:crypto";
+import path from "node:path";
+import type express from "express";
+import pc from "picocolors";
+import type {
+ AgentAdapter,
+ AgentEvent,
+ AgentRunContext,
+ AgentToolDefinition,
+ IAppRouter,
+ Message,
+ PluginPhase,
+ ResponseStreamEvent,
+ Thread,
+ 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 { isFromPluginMarker } from "./from-plugin";
+import { loadAgentsFromDir } from "./load-agents";
+import manifest from "./manifest.json";
+import { chatRequestSchema, invocationsRequestSchema } from "./schemas";
+import { buildBaseSystemPrompt, composeSystemPrompt } from "./system-prompt";
+import { InMemoryThreadStore } from "./thread-store";
+import { resolveToolkitFromProvider } from "./toolkit-resolver";
+import {
+ AppKitMcpClient,
+ type FunctionTool,
+ functionToolToDefinition,
+ isFunctionTool,
+ isHostedTool,
+ resolveHostedTools,
+} from "./tools";
+import { buildMcpHostPolicy } from "./tools/mcp-host-policy";
+import type {
+ AgentDefinition,
+ AgentsPluginConfig,
+ BaseSystemPromptOption,
+ PromptContext,
+ RegisteredAgent,
+ ResolvedToolEntry,
+} from "./types";
+import { isToolkitEntry } from "./types";
+
+const logger = createLogger("agents");
+
+const DEFAULT_AGENTS_DIR = "./config/agents";
+
+/**
+ * Context flag recorded on the in-memory AgentDefinition to indicate whether
+ * it came from markdown (file) or from user code. Drives the asymmetric
+ * `autoInheritTools` default.
+ */
+interface AgentSource {
+ origin: "file" | "code";
+}
+
+export class AgentsPlugin extends Plugin implements ToolProvider {
+ static manifest = manifest as PluginManifest;
+ static phase: PluginPhase = "deferred";
+
+ protected declare config: AgentsPluginConfig;
+
+ private agents = new Map();
+ private defaultAgentName: string | null = null;
+ private activeStreams = new Map();
+ private mcpClient: AppKitMcpClient | null = null;
+ private threadStore;
+
+ constructor(config: AgentsPluginConfig) {
+ super(config);
+ this.config = config;
+ this.threadStore = config.threadStore ?? new InMemoryThreadStore();
+ }
+
+ async setup() {
+ await this.loadAgents();
+ this.mountInvocationsRoute();
+ this.printRegistry();
+ }
+
+ /**
+ * Reload agents from the configured directory, preserving code-defined
+ * agents. Swaps the registry atomically at the end.
+ */
+ async reload(): Promise {
+ this.agents.clear();
+ this.defaultAgentName = null;
+ if (this.mcpClient) {
+ await this.mcpClient.close();
+ this.mcpClient = null;
+ }
+ await this.loadAgents();
+ }
+
+ private async loadAgents() {
+ const { defs: fileDefs, defaultAgent: fileDefault } =
+ await this.loadFileDefinitions();
+
+ const codeDefs = this.config.agents ?? {};
+
+ for (const name of Object.keys(fileDefs)) {
+ if (codeDefs[name]) {
+ logger.warn(
+ "Agent '%s' defined in both code and a markdown file. Code definition takes precedence.",
+ name,
+ );
+ }
+ }
+
+ const merged: Record =
+ {};
+ for (const [name, def] of Object.entries(fileDefs)) {
+ merged[name] = { def, src: { origin: "file" } };
+ }
+ for (const [name, def] of Object.entries(codeDefs)) {
+ merged[name] = { def, src: { origin: "code" } };
+ }
+
+ if (Object.keys(merged).length === 0) {
+ logger.info(
+ "No agents registered (no files in %s, no code-defined agents)",
+ this.resolvedAgentsDir() ?? "",
+ );
+ return;
+ }
+
+ for (const [name, { def, src }] of Object.entries(merged)) {
+ try {
+ const registered = await this.buildRegisteredAgent(name, def, src);
+ this.agents.set(name, registered);
+ if (!this.defaultAgentName) this.defaultAgentName = name;
+ } catch (err) {
+ throw new Error(
+ `Failed to register agent '${name}' (${src.origin}): ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ { cause: err instanceof Error ? err : undefined },
+ );
+ }
+ }
+
+ if (this.config.defaultAgent) {
+ if (!this.agents.has(this.config.defaultAgent)) {
+ throw new Error(
+ `defaultAgent '${this.config.defaultAgent}' is not registered. Available: ${Array.from(this.agents.keys()).join(", ")}`,
+ );
+ }
+ this.defaultAgentName = this.config.defaultAgent;
+ } else if (fileDefault && this.agents.has(fileDefault)) {
+ this.defaultAgentName = fileDefault;
+ }
+ }
+
+ private resolvedAgentsDir(): string | null {
+ if (this.config.dir === false) return null;
+ const dir = this.config.dir ?? DEFAULT_AGENTS_DIR;
+ return path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir);
+ }
+
+ private async loadFileDefinitions(): Promise<{
+ defs: Record;
+ defaultAgent: string | null;
+ }> {
+ const dir = this.resolvedAgentsDir();
+ if (!dir) return { defs: {}, defaultAgent: null };
+
+ const pluginToolProviders = this.pluginProviderIndex();
+ const ambient = this.config.tools ?? {};
+
+ const result = await loadAgentsFromDir(dir, {
+ defaultModel: this.config.defaultModel,
+ availableTools: ambient,
+ plugins: pluginToolProviders,
+ });
+
+ return result;
+ }
+
+ /**
+ * Builds the map of plugin-name → toolkit that the markdown loader consults
+ * when resolving `toolkits:` frontmatter entries.
+ */
+ private pluginProviderIndex(): Map<
+ string,
+ { toolkit: (opts?: unknown) => Record }
+ > {
+ const out = new Map();
+ if (!this.context) return out;
+ for (const { name, provider } of this.context.getToolProviders()) {
+ const withToolkit = provider as ToolProvider & {
+ toolkit?: (opts?: unknown) => Record;
+ };
+ if (typeof withToolkit.toolkit === "function") {
+ out.set(name, {
+ toolkit: withToolkit.toolkit.bind(withToolkit),
+ });
+ }
+ }
+ return out;
+ }
+
+ private async buildRegisteredAgent(
+ name: string,
+ def: AgentDefinition,
+ src: AgentSource,
+ ): Promise {
+ const adapter = await this.resolveAdapter(def, name);
+ const toolIndex = await this.buildToolIndex(name, def, src);
+
+ return {
+ name,
+ instructions: def.instructions,
+ adapter,
+ toolIndex,
+ baseSystemPrompt: def.baseSystemPrompt,
+ maxSteps: def.maxSteps,
+ maxTokens: def.maxTokens,
+ };
+ }
+
+ private async resolveAdapter(
+ def: AgentDefinition,
+ name: string,
+ ): Promise {
+ const source = def.model ?? this.config.defaultModel;
+ if (!source) {
+ const { DatabricksAdapter } = await import("../../agents/databricks");
+ try {
+ return await DatabricksAdapter.fromModelServing();
+ } catch (err) {
+ throw new Error(
+ `Agent '${name}' has no model configured and no DATABRICKS_AGENT_ENDPOINT default available`,
+ { cause: err instanceof Error ? err : undefined },
+ );
+ }
+ }
+ if (typeof source === "string") {
+ const { DatabricksAdapter } = await import("../../agents/databricks");
+ return DatabricksAdapter.fromModelServing(source);
+ }
+ return await source;
+ }
+
+ /**
+ * Resolves an agent's tool record into a per-agent dispatch index. Connects
+ * hosted tools via MCP client. Applies `autoInheritTools` defaults when the
+ * definition has no declared tools/agents.
+ */
+ private async buildToolIndex(
+ agentName: string,
+ def: AgentDefinition,
+ src: AgentSource,
+ ): Promise