Building a Production LangGraph Agent on Databricks: The NorthStar Brand Copilot
How we built and deployed an end-to-end CPG AI agent on Databricks using LangGraph, MCP servers, Lakebase memory, Genie, Vector Search, and MLflow and the best practices we learned along the way.
We built NorthStar Brand Copilot, an AI assistant for brand managers and field-sales reps at a fictional multi-category CPG company (Snacks, Beverages, Personal Care). It's a LangGraph custom agent that routes each question to the right Databricks-native capability:
Note: The CPG organization and data used in this blog post are fictional and intended for demonstration purposes only.
The full source code, including synthetic data generation, setup scripts and deployment automation, is available in the source_code_repo.
The whole thing runs inside a single Databricks App, deployed with Declarative Automation Bundles (DABs), Agent powered by Claude Sonnet 4.5, and observed/evaluated with MLflow. This post walks through the architecture and the practices that make it production-grade.
A few years ago, deploying an agent on Databricks meant logging a model to Unity Catalog and starting up a Model Serving endpoint. That still works, but the current recommended pattern is to run the agent inside a Databricks App as an MLflow ResponsesAgent served by AgentServer. We deliberately chose this newer path because it gives you:
Everything governed by Unity Catalog Ā· traces + eval in MLflow
The heart of this project is agent_server/agent.py. It's intentionally small, the framework does the heavy lifting. Here's the shape of it:
from databricks_langchain import ChatDatabricks
from langchain.agents import create_agent
from mlflow.genai.agent_server import invoke, stream
mlflow.langchain.autolog() # full tracing, one line
@stream()
async def stream_handler(request: ResponsesAgentRequest):
user_messages = to_chat_completions_input([i.model_dump() for i in request.input])
messages = {"messages": [{"role": "system", "content": AGENT_INSTRUCTIONS}] + user_messages}
tools = await _build_tools(sp_workspace_client) # Genie + Vector Search (MCP) + time
# ... open Lakebase memory store, add memory tools ...
agent = create_agent(tools=tools, model=ChatDatabricks(endpoint=MODEL_ENDPOINT))
async for event in process_agent_astream_events(agent.astream(...)):
yield event
We don't hand-code a supervisor graph with hard-wired branches. Instead we give a single create_agent a well-described tool set and a routing-oriented system prompt, and let Claude decide which tool(s) to call. The prompt is explicit about when to use each capability:
"Route quantitative questions to Genie. Route document-based or qualitative questions to Vector Search. For queries requiring both, call Genie first to retrieve numbers, then query AI Search(Formerly Vector Search) for guidance. Always cite sources. Never invent numbers."
This is the single highest-leverage piece of the agent. Tool descriptions and the system prompt are your routing logic. Invest there before you reach for custom graph code.
Gotcha: create_agent does not accept a prompt = argument the way older create_react_agent examples do. Prepend your instructions as a system message in the input state instead.
Make sure the _build_tools always returns something usable, even if a backend is down or a resource was deleted:
async def _build_tools(workspace_client):
tools = [get_current_time]
try:
mcp_client = init_mcp_client(workspace_client)
tools.extend(_stringify_tool(t) for t in await mcp_client.get_tools())
except Exception:
logger.warning("Failed to fetch MCP tools; continuing.", exc_info=True)
return tools
This mattered in practice: When the Vector Search index was later deleted / Not available in one workspace, the agent kept working, it just lost the RAG path instead of crashing. An agent that degrades is better than an agent returns a 500 error.
A subtle but critical fix. Databricks-managed MCP tools (Genie especially) return structured content blocks that include an id field. The Claude endpoint rejects that field in tool_result content with:
tool_result.content.0.text.id: Extra inputs are not permitted
The fix is a thin wrapper that coerces every MCP tool's output to a plain string:
def _stringify_tool(t: StructuredTool) -> StructuredTool:
async def _wrapped(**kwargs):
out = await t.ainvoke(kwargs)
return out if isinstance(out, str) else json.dumps(out, default=str)
return StructuredTool(
name=t.name, description=t.description, args_schema=t.args_schema, coroutine=_wrapped
)
Lesson: when you bridge MCP tools to a specific model endpoint, validate the tool-result schema. Coercing to plain text is a safe default.
The model endpoint, Genie space, AI Search(Formerly Vector Search) catalog/schema, Lakebase instance, and embedding config are all environment variables with sensible defaults, set in databricks.yml / app.yaml. We pin a specific, capable model ā databricks-claude-sonnet-4-5, rather than a floating alias. This keeps behavior reproducible across the multiple workspaces if deployed to.
Instead of writing bespoke API clients for Genie and AI Search(Formerly Vectory Search), the agent consumes them as MCP (Model Context Protocol) servers. Databricks exposes managed MCP endpoints for its services, and databricks-langchain gives you a client that turns them into LangChain tools:
from databricks_langchain import DatabricksMCPServer, DatabricksMultiServerMCPClient
def init_mcp_client(workspace_client):
host = get_databricks_host_from_env()
return DatabricksMultiServerMCPClient([
DatabricksMCPServer(
name="genie",
url=f"{host}/api/2.0/mcp/genie/{GENIE_SPACE_ID}",
workspace_client=workspace_client,
),
DatabricksMCPServer(
name="vector-search",
url=f"{host}/api/2.0/mcp/vector-search/{VS_CATALOG}/{VS_SCHEMA}",
workspace_client=workspace_client,
),
])
tools = await mcp_client.get_tools()
Note: We selected MCP as our preferred standard because it offers a unified, extensible protocol for tool integration. While Genie APIs or standard UC functions could handle specific tasks, MCP provides a future-proof framework. We anticipate expanding the Copilot's capabilities with a broader array of tools and external services, and MCPās standardized interface significantly simplifies this long-term extensibility.
There are three tool types worth knowing on the platform:
This is what turns a chatbot into a copilot. NorthStar remembers decisions ("we decided to cut BOGO(Buy One Get One) at Walgreens"), flags action items, and recalls them later, even in a brand-new session.
Databricks recognizes two kinds of memory:
|
Type |
Use case |
Backing |
Key |
|
Short-term |
History within one session |
AsyncCheckpointSaver |
thread_id |
|
Long-term |
Facts that persist across sessions |
AsyncDatabricksStore |
user_id |
NorthStar uses long-term memory on Lakebase (Databricks' managed Postgres) via AsyncDatabricksStore, with semantic search powered by the same databricks-gte-large-en embedding endpoint used elsewhere.
The store is opened as an async context manager and threaded into the agent through RunnableConfig:
async with store_cm as store:
await store.setup() # idempotent; creates store tables on first use
tools = tools + memory_tools()
agent = create_agent(tools=tools, model=ChatDatabricks(endpoint=MODEL_ENDPOINT))
config = {"configurable": {"user_id": user_id, "store": store}}
async for event in process_agent_astream_events(agent.astream(input=messages, config=config, ...)):
yield event
Memory is exposed to the model as three tools, returned from a factory function:
The factory pattern lets each tool close over nothing but RunnableConfig, from which it pulls the store and user_id. Memories are namespaced per user: ("user_memories", user_id.replace(".", "-")).
Two deployment gotchas, both real:
Just like tool-building, memory degrades gracefully ā if databricks-langchain[memory] isn't installed or Lakebase is unreachable, the agent drops the memory tools and keeps answering.
Beyond serving as a persistent key-value store, Lakebase acts as a durable, managed storage layer that allows agents to maintain state beyond short-lived session histories. By leveraging managed Postgres, it provides a consistent, reliable backend that ensures agent memory remains available even when compute clusters spin down or sessions expire, effectively turning stateless LLM calls into a continuous, learning assistant.
As with any long-term memory system, stale or outdated information can degrade agent performance. To maintain relevance, implement the following strategies:
The demo is a tour of the platform's GenAI surface area:
|
Capability |
Databricks feature |
Role in the agent |
|
Analytics (NLāSQL) |
Genie space over 7 governed Delta tables |
Quantitative questions: ROI, sell-through, inventory |
|
Document insights (RAG) |
Vector Search (Delta-sync index, databricks-gte-large-en) |
Specs, allergens, reviews, playbook, briefs |
|
Long-term memory |
Lakebase (managed Postgres) + AsyncDatabricksStore |
Persist & recall decisions across sessions |
|
LLM |
Foundation Model API ā databricks-claude-sonnet-4-5 |
Reasoning + routing |
|
Governance |
Unity Catalog |
Every table, index, and function authorized via grants |
|
Hosting |
Databricks Apps (FastAPI + custom SPA) |
One app: Dashboard tab + Assistant tab |
|
Packaging |
Automation Bundles (DABs) |
Declarative deploy + resource permissions |
|
Observability |
MLflow Tracing (autolog) |
Per-request span tree: routing ā tool ā LLM |
|
Quality |
MLflow Agent Evaluation |
Scored on Correctness / Relevance / Safety |
A nice touch: the app serves a custom two-tab SPA (vanilla JS + Chart.js) from the same FastAPI process ā a Dashboard tab backed by a /api/analytics SQL endpoint, and an Assistant tab that calls /invocations. Running the app backend-only (command: ["uv", "run", "start-server"]) means the FastAPI app serves both the UI and the agent ā no separate Node/Next.js frontend to build.
When moving from prototype to production, ensuring stability and safety is non-negotiable.
How do you know it's ready for users? We treat the application as a software product.
mlflow.langchain.autolog() captures the entire agent run. The trick for clean traces: wrap each invocation in a parent span (@mlflow.trace(name=..., span_type="AGENT")), otherwise autolog emits fragmented one-span-per-call traces. With the parent span you get a single unified trace:
northstar_brand_copilot ā LangGraph ā ChatDatabricks ā [Genie query OR Vector Search retrieve] ā ChatDatabricks
For quality, mlflow.genai.evaluate(...) runs built-in judge scorers over a curated 10-question CPG eval set:
Safety 1.0 Ā· RelevanceToQuery 0.9 Ā· Correctness 0.8
Gotcha: the GenAI judge scorers require the databricks-agents package. Without it, scorers fail silently ā assessments come back None, which looks like passing tests but isn't. Install databricks-agents before evaluating.
MLFlow Trace:
MLFlow Evaluations:
Everything is declared in databricks.yml. The app, its run command, every environment variable, and ā crucially ā every resource grant the service principal needs:
resources:
apps:
agent_langgraph:
name: "northstar-brand-copilot"
source_code_path: ./
config:
command: ["uv", "run", "start-server"]
env:
- { name: MODEL_ENDPOINT, value: "databricks-claude-sonnet-4-5" }
- { name: GENIE_SPACE_ID, value: "01f1..." }
- { name: LAKEBASE_INSTANCE_NAME, value_from: "database" }
resources:
- name: llm # CAN_QUERY on the Claude endpoint
- name: embedding # CAN_QUERY on the embedding endpoint
- name: genie_space # CAN_RUN on the Genie space
- name: vector_index # SELECT on the VS index (uc_securable)
- name: database # CAN_CONNECT_AND_CREATE on Lakebase
- name: warehouse # CAN_USE on the SQL warehouse
- name: experiment # CAN_MANAGE on the MLflow experiment
Note that running the app (databricks bundle run) after deployment is not optional. deploy (databricks bundle deploy) uploads code and reconciles resources; bundle run is what actually restarts the app, Avoiding it would be testing stale code.
While the bundle grants the SP access to endpoints like the Genie space, Vector Search index, and Lakebase instance, additional permissions are needed for execution. Specifically, since Genie runs SQL queries under the service principal's identity, the SP must be granted Unity Catalog table access and SQL Warehouse usage. Similarly, Lakebase requires configuring a dedicated Postgres role. Those are applied by deployment/grant_resources.py:
More gotchas from the trenches:
Authentication: Deployed Databricks Apps require an OAuth token for API requests; Personal Access Tokens (PATs) will not work.
Quick fix: Retrieve your token using: databricks auth token ... | jq -r .access_token
YAML Configuration Syntax: Under the sql_warehouse resource configuration, the correct property name is id:, not sql_warehouse_id: (a common typo in some documentation templates).
Terraform State Loss Recovery: If your Terraform state is lost, running databricks bundle deploy will attempt to re-grant permissions on all resources, which fails on the Lakebase database grant.
Workaround: Because bundle deploy uploads the source code before it fails during grant reconciliation, you can complete the deployment by running databricks apps deploy <app> --source-code-path <bundle files path> to update the application code directly without triggering resource reconciliation.
Figure 1(Dashbaord):
Figure 2(Agent):
Figure 3(Agent Memory: Saved to Lakebase):
Latency is a critical metric for a responsive Copilot experience. Since the agent invokes multiple tools and LLM endpoints sequentially, we prioritize streaming responses. By utilizing streaming responses and optimizing tool execution, we ensure that the initial token latency is kept to a minimum, providing a snappy, responsive interface.
The result is a governed, observable, reproducible agent that answers real CPG business questions ā and a blueprint you can lift for your own domain.
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.