Skip to content

feat(appkit): add AgentPlugin for LangChain/LangGraph agents#166

Open
hubertzub-db wants to merge 2 commits intodatabricks:mainfrom
hubertzub-db:feat/agent-plugin
Open

feat(appkit): add AgentPlugin for LangChain/LangGraph agents#166
hubertzub-db wants to merge 2 commits intodatabricks:mainfrom
hubertzub-db:feat/agent-plugin

Conversation

@hubertzub-db
Copy link

@hubertzub-db hubertzub-db commented Mar 9, 2026

Adds a new first-class plugin for building AI agents.

PR Reading Guide

This guide helps reviewers navigate the ~3,400-line commit. Changes are grouped by area with suggested reading order and file-by-file notes.


1. Agent plugin (core)

Location: packages/appkit/src/plugins/agent/

The plugin is a first-class AppKit plugin that exposes POST /api/agent and supports:

  • Bring-your-own agent via config.agentInstance (any AgentInterface implementation)
  • Auto-built LangGraph ReAct agent from config (model, tools, MCP servers)

Suggested order

Order File Purpose
1 types.ts Config: IAgentConfig (agentInstance, model, systemPrompt, tools, mcpServers, etc.) and BasePluginConfig extension.
2 agent-interface.ts Contract: InvokeParams, ResponseStreamEvent (SSE event union), ResponseOutputItem, and AgentInterface (invoke(), stream()). No OpenAI dependency; mirrors Responses API shape.
3 manifest.json Plugin manifest: name, displayName, description, required resource serving_endpoint with DATABRICKS_MODEL env.
4 standard-agent.ts StandardAgent implements AgentInterface: wraps LangGraph createReactAgent, converts messages to/from InvokeParams and stream events to Responses API SSE. Defines LangGraphAgent minimal interface.
5 invoke-handler.ts Express handler: Zod-validated request body (input, stream, optional model), flattens history for InvokeParams, calls agent stream() or invoke(), writes SSE (including [DONE]).
6 agent.ts AgentPlugin class: setup() (use agentInstance or build LangGraph agent with ChatDatabricks + tools/MCP), buildStandardAgent(), addTools() / addMcpServers(), injectRoutes() (POST / → handler), exports() (invoke, stream, addTools, addMcpServers), abortActiveOperations() (MCP client cleanup). Factory agent() via toPlugin.
7 index.ts Re-exports plugin, types, createInvokeHandler, StandardAgent, LangGraphAgent.

Dependencies: Plugin uses optional peer deps: @databricks/langchainjs, @langchain/core, @langchain/langgraph, @langchain/mcp-adapters (see packages/appkit/package.json).


2. Agent plugin – tests

Location: packages/appkit/src/plugins/agent/tests/

File What it covers
stub-agent.ts In-memory AgentInterface implementation used by tests: echo-style invoke() and stream() that emit known SSE events.
agent.test.ts Unit tests for AgentPlugin: factory/manifest/name, setup() with agentInstance vs config (model required), addTools/addMcpServers (only when not using agentInstance), route registration. Uses mockServiceContext, setupDatabricksEnv, mocks CacheManager.
invoke-handler.test.ts Unit tests for createInvokeHandler: streaming (SSE format, [DONE], output_item.added/done, text deltas), non-streaming (JSON array), error handling, request validation (Zod), flattenHistoryItem for various message shapes. Uses createMockRequest/createMockResponse and parses SSE from res.write chunks.
agent.integration.test.ts Integration tests: real HTTP server with createApp + agent + server plugins (StubAgent), POST /api/agent streaming and non-streaming, error cases, and (if present) addTools affecting behavior.

How to run: From repo root, pnpm test or filter: pnpm test -- packages/appkit/src/plugins/agent.


3. appkit-ui changes

Location: packages/appkit-ui/src/react/agent-chat/

React components and hooks for a chat UI that talk to POST /invocations (Responses API SSE). The backend can expose that path by rewriting to /api/agent (see dev-playground).

Suggested order

Order File Purpose
1 types.ts UI types: AssistantPart (text, function_call, function_call_output), ChatMessage (user | assistant with parts), SSEEvent/SSEItem, UseAgentChatOptions/UseAgentChatReturn, AgentChatProps.
2 utils.ts serializeForApi(msg): converts ChatMessage to request body (user content or assistant content array). tryFormatJson(s): pretty-print JSON for display.
3 use-agent-chat.ts Hook: state (messages, streamingParts, streamingText, loading, input), handleSubmit (POST with input + stream: true, read SSE stream, parse events, update messages/parts), displayMessages (messages + current streaming message), isStreamingText. Handles response.output_item.added, response.output_text.delta, response.completed, [DONE], errors.
4 agent-chat-part.tsx Renders one assistant part: text (with optional cursor), function_call (name + args), function_call_output (output). Uses tryFormatJson.
5 agent-chat-message.tsx Renders one message: user bubble (right) or assistant (left) with list of AgentChatPart.
6 agent-chat.tsx AgentChat: scrollable message list, input form, uses useAgentChat; auto-scroll on new content/streaming.
7 index.ts Re-exports for @databricks/appkit-ui/react.

Barrel: packages/appkit-ui/src/react/index.ts – adds AgentChat (and any hook/type exports if present).


4. apps/dev-playground updates

The playground wires the Agent Plugin into the app (server), adds an Agent Chat page (client), and documents the required env var.

Server

server/index.ts

  • Plugin registration: agent() is added to the plugins array with:
    • model: process.env.DATABRICKS_MODEL or fallback "databricks-claude-sonnet-4-5".
    • systemPrompt: instructs the assistant to use tools (e.g. get_weather, get_current_time) when relevant.
  • Post-create tool registration: In the .then() after createApp, appkit.agent.addTools(demoTools) is called so the built-in LangGraph agent gets the demo tools without putting them in initial config (shows the “add tools after app creation” pattern).
  • /invocations rewrite: Inside server.extend(), a POST /invocations route is added that sets req.url = "/api/agent" and re-dispatches into the Express app. That way the client can call the standard Databricks Apps path /invocations while the plugin still serves its canonical route /api/agent.

server/agent-tools.ts (new file)

  • Purpose: Two LangChain StructuredTools used by the agent in the playground.
  • get_weather: Takes location (string), returns a short fake weather summary (random condition and temp). Schema: z.object({ location: z.string().describe(...) }).
  • get_current_time: Takes optional timezone (IANA, default UTC), returns current date/time in that zone. Schema: z.object({ timezone: z.string().optional().describe(...) }).
  • patchZodSchema: Workaround because @databricks/langchainjs@0.1.0 expects schema.toJSONSchema() as a method, while zod v4 exposes toJSONSchema(schema) as a standalone. The helper attaches .toJSONSchema on the schema so the LangChain/ChatDatabricks integration works. Both tools use it; they are exported as demoTools.

Client

client/src/routes/agent.route.tsx (new file)

  • Defines the /agent route via createFileRoute("/agent").
  • Layout: Full-height page with a header (“Agent Chat” + one-line description mentioning POST /invocations and Responses API SSE), then a single <AgentChat> that fills the remaining height.
  • AgentChat props: invokeUrl="/invocations" (hits the server rewrite above), plus placeholder, empty-message, and className for flex layout.

client/src/routes/__root.tsx

  • Adds a nav entry: “Agent” button linking to /agent, in the same style as Analytics, Genie, etc.

client/src/routes/index.tsx

  • Adds an “Agent Chat” card on the home page: title, short blurb (LangChain/LangGraph, Responses API SSE, tool calls), and “Try Agent Chat” button that navigates to /agent.

client/src/routeTree.gen.ts

  • Generated by TanStack Router; updated to include the new /agent route (no hand edits).

Config

apps/dev-playground/.env.dist

  • New line DATABRICKS_MODEL= so developers know they must set the model serving endpoint name for the agent plugin (required when not using agentInstance).

5. Docs updates

Location: docs/docs/api/appkit/

Typedoc-generated API docs (and sidebar) for the new public surface.

File Content
Interface.AgentInterface.md Agent contract (invoke, stream).
Interface.IAgentConfig.md Plugin config options.
Interface.InvokeParams.md Request shape (input, chat_history).
Interface.StandardAgent.md LangGraph wrapper.
Interface.BasePluginConfig.md Base config (updated to mention agent).
TypeAlias.ResponseStreamEvent.md SSE event union.
Variable.agent.md agent factory.
index.md Exports index updated.
typedoc-sidebar.ts Sidebar updated for new entries.

6. Other changes

Location Change
packages/appkit/package.json New optional peer dependencies for LangChain/LangGraph/MCP; dev deps for tests.
packages/appkit/src/index.ts Re-exports agent plugin and types (e.g. agent, AgentPlugin, AgentInterface).
packages/appkit/src/plugins/index.ts Registers agent plugin in plugins list.
pnpm-lock.yaml Lockfile updates for new/optional deps.
template/appkit.plugins.json Adds agent plugin entry (name, displayName, description, package, resources matching manifest).

How to read this PR

  1. Get the contract
    Read agent-interface.ts and types.ts so you know AgentInterface, InvokeParams, and SSE event shapes.

  2. See how the plugin is used
    Skim apps/dev-playground/server/index.ts: plugin registration, addTools(demoTools), and the /invocations/api/agent rewrite. Optionally open agent-tools.ts for the demo tools.

  3. Trace the request path
    Follow a request: invoke-handler (Zod + flatten history) → agent (get implementation) → standard-agent (LangGraph → SSE). Read invoke-handler.ts then standard-agent.ts then the streaming part of agent.ts.

  4. Plugin lifecycle and API
    Read agent.ts: setup() (agentInstance vs build), buildStandardAgent(), addTools/addMcpServers, injectRoutes, exports(), cleanup in abortActiveOperations().

  5. UI and E2E flow
    In appkit-ui read use-agent-chat.ts (state + SSE parsing), then agent-chat.tsx and agent-chat-message.tsx / agent-chat-part.tsx. In the app, open agent.route.tsx to see the page and invokeUrl="/invocations".

  6. Tests
    Run tests, then read stub-agent.ts, invoke-handler.test.ts (SSE and validation), agent.test.ts (plugin behavior), and agent.integration.test.ts (HTTP).

  7. Manifest and config
    Check manifest.json and template/appkit.plugins.json for resources and template wiring.

  8. Docs
    Use docs/docs/api/appkit/ for reference; no need to read every doc file unless you care about generated output.

Review focus: Contract in agent-interface.ts, correctness of SSE in invoke-handler and standard-agent, plugin lifecycle and thread-safety of addTools/addMcpServers, and that the UI correctly parses the same SSE events the backend emits.

</p>
</div>

<AgentChat
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

N/B: we're keeping a minimal UI for now just to be able to chat, until we port the "full" chat UI as a separate plugin

}

/** Renders a single chat message bubble (user or assistant with parts). */
export function AgentChatMessage({
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

N/B: initially I aimed to reuse UI bits from genie plugin, but it's probably not worth the effort since we want to move a fully featured chat UI as a separate plugin soon

@hubertzub-db hubertzub-db force-pushed the feat/agent-plugin branch 2 times, most recently from fd1bffc to ba9ad8d Compare March 11, 2026 15:45
Copy link

@dhruv0811 dhruv0811 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is super cool, looks great overall!

I was able to try it out locally with both the BYOA and auto build langgraph agent. I'm new to this repo so I have a few questions about why we are doing things the way we are. Thanks for working on this :)

case "user":
return new HumanMessage(content);
case "system":
return new SystemMessage(content);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious if there is a reason we dropped the "assistant" case here?

case "assistant":
        return { role: "assistant", content } as any;

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh that seems to be a migration artifact, fixing now

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New to this repo, so these might be obvious questions but curious about the following:

  1. Why are defining these types on our own rather than taking on the openai dependency?
  2. If we do it this way, are we worried about API drift? Between our definitions here and OpenAI resonses?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if I understand the current implementation of agent-langchain-ts in app-templates correctly, it's to avoid pulling a really large package just to get a handful of TS types. I think it makes sense to do the same here.
The types are standard, simple and expected to be stable in the long term so I think it's a justified risk compared to cost of pulling in a large dep.


addTools: (tools: StructuredToolInterface[]) => this.addTools(tools),
addMcpServers: (servers: DatabricksMCPServer[]) =>
this.addMcpServers(servers),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very nit: when we do sequential addTools and addMcpServers calls like this, aren't we re-building the agent twice? instead maybe we can introduce a wrapper/batch function which can looks something like below for a single rebuild:

  await appkit.agent.configure({
    tools: [...demoTools],
    mcpServers: [ucServer],
  }); // single rebuild

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point! let me add another method

const outputItem = {
id: `fco_${randomUUID()}`,
call_id: callId,
output: JSON.stringify(event.data?.output || ""),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UI nit: this returns a fill ToolMessage object with a lot of other metadata in it as you can see in the attached sc. Perhaps we can clean up this up a bit to show only the relevant sections to be more human readable?

Image

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm the approach was to pack as much data as possible, leaving it up to the UI to decide what's used for display (the current dev-playground chat UI just happens to display all for demo purposes). What do you think?

Agent Chat
</h3>
<p className="text-muted-foreground mb-6 flex-grow">
Chat with a LangChain/LangGraph AI agent powered by the AppKit

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: LangChain is an implementation detail, no need to mention it here and elsewhere

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In particular, we want the right to change our implementation to use another agent framework / SDK down the line if that's preferable

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree, updating the label

</h3>
<p className="text-muted-foreground mb-6 flex-grow">
Chat with a LangChain/LangGraph AI agent powered by the AppKit
Agent Plugin. Features Responses API SSE streaming and tool call

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Responses API -> OpenResponses-compatible

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(To go from vendor-specific -> OSS, see https://www.openresponses.org/ for more context)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👌

import { toJSONSchema } from "zod/v4/core";

/**
* Workaround: @databricks/langchainjs@0.1.0 calls `schema.toJSONSchema()`

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, curious why this is needed - do we need a bugfix in https://www.npmjs.com/package/@databricks/langchainjs?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Or update for zod4 compatibility?)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a workaround due to dev-playground using older zod. Agree, let me bump local zod instead

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update: actually it's not needed anymore after redoing tools to follow OpenResponses API

agent({
model: process.env.DATABRICKS_MODEL || "databricks-claude-sonnet-4-5",
systemPrompt:
"You are a helpful assistant. Use tools when appropriate — for example, use get_weather for weather questions, and get_current_time for time queries.",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible for users to pass tools here? It seems a little counterintuitive to have to call appkit.agent.addTools later. Or maybe here the user could have the option to pass either a list of tools, or a function that accepts an appkit and returns a list of tools, which would get called in the agent implementation to get the list of tools? In general, I think it'd be better to start with just a list of tools here for simplicity - we can see if it makes sense to support the (appkit) => listOftools type later

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see it is possible to pass a list of tools directly - that makes sense, but I don't think we want to reuse the LangChain tool type (see my comment RE OpenResponses below). In particular I wonder if it makes sense to do something like support passing either typescript function objects (locally-defined tool implementations) or structured OpenResponses-style hosted tool definitions like {"type": "genie", "genie_space": {"id": ...}}. Happy to discuss more how the hosted tool piece should work - it should be possible to translate a spec of that format into a call to a Databricks managed MCP server

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Correct, it's possible to add tools/MCPs either at initialization or later (anticipating for some plugin interop in the future).
  2. let me address in the comment below

...(process.env.APPKIT_E2E_TEST && { client: createMockClient() }),
}).then((appkit) => {
}).then(async (appkit) => {
// Add tools (and optionally MCP servers) after app creation

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally the interface/format for tools would match OpenResponses conventions, as outlined in this doc. AI can probably help make this change given https://github.com/openresponses/openresponses/blob/main/public/openapi/openapi.json#L1225 for reference :P

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redone tools API to use standard openresponses format

@@ -0,0 +1,43 @@
# Interface: AgentInterface

Contract that agent implementations must fulfil.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, AFAICT this allows basically plugging in our existing TypeScript agent implementation from https://github.com/databricks/app-templates/tree/main/agent-langchain-ts, which is great. Later we may want a way to get an agentInstance from an existing OpenResponses-compatible agent endpoint or Agent Brick on Databricks, e.g. for the chat UI. We can add support for that later though

Copy link

@smurching smurching Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Or maybe the chat UI plugin should just support running against such an endpoint, i.e. accept endpointName: agentBrickEndpointName as an option; that's probably cleaner)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Or maybe the chat UI plugin should just support running against such an endpoint, i.e. accept endpointName: agentBrickEndpointName as an option; that's probably cleaner)

yeah I think we should allow chatUI to be configured to target

  • any hosted model on databricks
  • hosted agent bricks
  • agent plugin running in the same app

### mcpServers?

```ts
optional mcpServers: DatabricksMCPServer[];

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't be needed if we support OpenResponses/OpenAI Responses-style MCP tool references e.g.

{"type": "genie", "genie_space": {"id": "..."}},
{"type": "vector_search_index", "vector_search_index": {"name": "catalog.schema.index"}},
{"type": "custom_mcp_server", "custom_mcp_server": {"app_name": "..."}},
{"type": "external_mcp_server", "external_mcp_server": {"connection_name": "..."}}

Open to adding classes (GenieTool, VectorSearchIndexTool, etc) to simplify declaring these, but it should also be possible to just pass the OpenResponses-style tool definition here

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm I might not have enough context, why the DatabricksMCPServer abstraction was introduced then?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's useful as a building block for our AppKit implementation - it handles auth to MCP servers on Databricks. We can support e.g. a declarative Genie tool in AppKit by using the Genie managed MCP server via DatabricksMCPServer, but we shouldn't ask the user to pass in a DatabricksMCPServer reference to the Genie managed MCP server - it's better to just ask them for the Genie spaces they'd like to add as tools, etc.

Concretely this:

tools: [{"type": "genie", "genie_space": {"id": "..."}}, ...]

is better than this:

mcpServers: DatabricksMCPServer.fromGenieSpace(id)

since the former doesn't require learning which Databricks resources should be accessed via MCP servers, etc

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>

/**
* Tools to register with the agent. Accepts OpenResponses-aligned FunctionTool
* objects or LangChain StructuredToolInterface instances.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QQ: is LangChain StructuredToolInterface needed? My hope is to avoid exposing LangChain types in our user-facing AppKit interface, they should be an implementation detail. Instead we'd ideally support other OpenResponses-aligned types, e.g.

{"type": "genie", "genie_space": {"id": ...}}

In addition to the FunctionTool type

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants