<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: duke</title>
    <description>The latest articles on DEV Community by duke (@javaking1129).</description>
    <link>https://dev.to/javaking1129</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3998775%2F0566aeb5-f982-42fb-980a-19d6632a7dd2.png</url>
      <title>DEV Community: duke</title>
      <link>https://dev.to/javaking1129</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/javaking1129"/>
    <language>en</language>
    <item>
      <title>[Databricks on AWS #6] How We Structure the Terraform: Terragrunt, YAML-Driven Modules, and Atlantis GitOps</title>
      <dc:creator>duke</dc:creator>
      <pubDate>Thu, 09 Jul 2026 01:08:38 +0000</pubDate>
      <link>https://dev.to/javaking1129/databricks-on-aws-6-how-we-structure-the-terraform-terragrunt-yaml-driven-modules-and-4959</link>
      <guid>https://dev.to/javaking1129/databricks-on-aws-6-how-we-structure-the-terraform-terragrunt-yaml-driven-modules-and-4959</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;📚 Series: Databricks on AWS (Part 6)&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Building a Databricks AI Platform on AWS&lt;/li&gt;
&lt;li&gt;RBAC with Function-Role Groups&lt;/li&gt;
&lt;li&gt;Compute Governance: Pools, Policies, Clusters&lt;/li&gt;
&lt;li&gt;The BOOTSTRAP_TIMEOUT Mystery&lt;/li&gt;
&lt;li&gt;Fixing It with AWS PrivateLink&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How We Structure the Terraform&lt;/strong&gt; ← you are here&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Five parts of &lt;em&gt;what&lt;/em&gt; we built — the workspaces, the RBAC, the compute, the network mystery, the PrivateLink fix. This one is the &lt;em&gt;how&lt;/em&gt;: the repo layout and Terragrunt patterns that hold it all together, plus the two footguns that will bite you on day one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Over five parts we stood up an AI platform on Databricks + AWS. Along the way I kept saying "we apply this," "the module for that," "the provider override" — and mostly waved my hands at the plumbing. This finale is the plumbing: how the Terraform is actually organized, why humans never touch a &lt;code&gt;.tf&lt;/code&gt; file, and the two ordering gotchas that turn a clean plan into a red one.&lt;/p&gt;

&lt;p&gt;If you're standing up a multi-workspace Databricks estate and want a layout that scales past the first workspace without copy-paste sprawl, this is the shape that worked for us.&lt;/p&gt;




&lt;h2&gt;
  
  
  The repo, from the top
&lt;/h2&gt;

&lt;p&gt;Two ideas do all the work: &lt;strong&gt;reusable modules&lt;/strong&gt; and &lt;strong&gt;per-environment trees&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;infra/
├── _modules/               # reusable Terraform modules (the "what")
├── &amp;lt;project&amp;gt;/
│   └── databricks/
│       ├── dev/            # per-env tree (the "where")
│       └── prd/
├── common_vars.yaml        # shared config: hosts, account IDs, CIDRs
└── provider.tmpl           # provider template
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;_modules/&lt;/code&gt; is a flat library of single-purpose Terraform modules — one per resource concept:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;workspace, unity-catalog, catalog, schema, grants, group, group-ar,
instance-pool, cluster-policy, compute, sql-warehouse, entitlements,
external-location, workspace-assignment, acls, iamrole, iampolicy,
s3, kms, vpc, service-principal, audit-log-delivery, workspace-conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these know about &lt;code&gt;dev&lt;/code&gt; or &lt;code&gt;prd&lt;/code&gt;. They take inputs and create resources. The environment tree decides &lt;em&gt;which&lt;/em&gt; modules run, &lt;em&gt;with what values&lt;/em&gt;, in &lt;em&gt;what order&lt;/em&gt;. A new workspace is a new folder under &lt;code&gt;dev/&lt;/code&gt;, not a new module.&lt;/p&gt;

&lt;h2&gt;
  
  
  Humans write YAML, not HCL
&lt;/h2&gt;

&lt;p&gt;Here's the rule we enforce: &lt;strong&gt;a person editing this repo edits YAML.&lt;/strong&gt; They don't write Terraform, and mostly don't write Terragrunt either.&lt;/p&gt;

&lt;p&gt;The flow is three hops:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;human-authored *.yaml
  → terragrunt.hcl: yamldecode() + env-prefix
  → Terraform module inputs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each leaf directory has a small &lt;code&gt;*.yaml&lt;/code&gt; (the desired state, in human terms) and a &lt;code&gt;terragrunt.hcl&lt;/code&gt; that reads it. The &lt;code&gt;terragrunt.hcl&lt;/code&gt; does &lt;code&gt;yamldecode()&lt;/code&gt; on the YAML, then rewrites the keys with an environment prefix before handing them to the module. Concretely, in the &lt;code&gt;dev&lt;/code&gt; tree an instance-pool key like &lt;code&gt;ip_cpu_small&lt;/code&gt; becomes &lt;code&gt;dev_ip_cpu_small&lt;/code&gt; on the way in.&lt;/p&gt;

&lt;p&gt;Why the prefix? Because the &lt;em&gt;same&lt;/em&gt; YAML shape lives in &lt;code&gt;dev/&lt;/code&gt; and &lt;code&gt;prd/&lt;/code&gt;, but the resource names, pool IDs, and group names must be globally distinct. The env prefix is injected once, in one place, by Terragrunt — so no human ever hand-types &lt;code&gt;dev_&lt;/code&gt; or &lt;code&gt;prd_&lt;/code&gt; and nobody fat-fingers a cross-environment collision. You change a number in YAML; the naming discipline is automatic.&lt;/p&gt;

&lt;p&gt;This is the single highest-leverage decision in the whole layout. The reviewer of a merge request reads a YAML diff — "pool max went 8 → 16" — not a wall of HCL.&lt;/p&gt;

&lt;h2&gt;
  
  
  One provider override per workspace
&lt;/h2&gt;

&lt;p&gt;Databricks has a wrinkle that AWS doesn't: &lt;strong&gt;every workspace is its own API host.&lt;/strong&gt; A &lt;code&gt;databricks_cluster&lt;/code&gt; in workspace A and one in workspace B are created against different &lt;code&gt;host&lt;/code&gt; URLs, even though it's the same account, same provider, same code.&lt;/p&gt;

&lt;p&gt;We solve this with generated provider overrides. Each workspace layer knows its &lt;code&gt;workspace_key&lt;/code&gt; (from a small &lt;code&gt;workspace.hcl&lt;/code&gt;), and &lt;code&gt;common_vars.yaml&lt;/code&gt; maps that key to the workspace host. Terragrunt looks up the host and &lt;strong&gt;generates&lt;/strong&gt; a &lt;code&gt;provider_override.tf&lt;/code&gt; on the fly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ws_host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common_vars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;databricks&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workspace_hosts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ws_vars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workspace_key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;generate&lt;/span&gt; &lt;span class="s2"&gt;"workspace_provider_override"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"provider_override.tf"&lt;/span&gt;
  &lt;span class="nx"&gt;contents&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;-&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
    provider "databricks" {
      host = "${local.ws_host}"
    }
&lt;/span&gt;&lt;span class="no"&gt;  EOF
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The module code is workspace-agnostic. The &lt;em&gt;host&lt;/em&gt; is injected at plan time based on which folder you're in. Add a workspace, add its host to &lt;code&gt;common_vars.yaml&lt;/code&gt;, drop a &lt;code&gt;workspace.hcl&lt;/code&gt; with the key — every layer underneath now targets the right host with zero code changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two Databricks providers, and why
&lt;/h2&gt;

&lt;p&gt;You'll notice the code uses the Databricks provider two different ways, and it's not an accident:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;provider&lt;/th&gt;
&lt;th&gt;scope&lt;/th&gt;
&lt;th&gt;used for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;databricks.mws&lt;/code&gt; (account-level)&lt;/td&gt;
&lt;td&gt;the Databricks &lt;strong&gt;account&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;creating workspaces, account-level groups, metastore assignment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;databricks&lt;/code&gt; (workspace-level)&lt;/td&gt;
&lt;td&gt;a single &lt;strong&gt;workspace&lt;/strong&gt; host&lt;/td&gt;
&lt;td&gt;clusters, pools, policies, catalogs, grants, ACLs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Account-level resources (the workspace &lt;em&gt;itself&lt;/em&gt;, account groups, wiring a metastore) don't belong to any one workspace — they're configured against the account API, which is why they use the &lt;code&gt;mws&lt;/code&gt; alias. Everything &lt;em&gt;inside&lt;/em&gt; a workspace uses the workspace-scoped provider with the host we just injected. Mixing these up is a classic early mistake: try to create a cluster with the &lt;code&gt;mws&lt;/code&gt; provider and you'll get authentication errors that make no sense until you realize you're pointed at the account, not the workspace.&lt;/p&gt;

&lt;h2&gt;
  
  
  dependency + mock_outputs (and the "init" gotcha)
&lt;/h2&gt;

&lt;p&gt;Layers depend on each other's outputs. &lt;code&gt;cluster-policy&lt;/code&gt; needs the pool IDs that &lt;code&gt;instance-pool&lt;/code&gt; produced; &lt;code&gt;compute&lt;/code&gt; needs the policy IDs. Terragrunt expresses this with a &lt;code&gt;dependency&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;dependency&lt;/span&gt; &lt;span class="s2"&gt;"instance_pool"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;config_path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"../instance-pool"&lt;/span&gt;
  &lt;span class="nx"&gt;mock_outputs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;instance_pool_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"dev_ip_cpu_small"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"mock-pool-id-1"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;mock_outputs_allowed_terraform_commands&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"validate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"plan"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;mock_outputs&lt;/code&gt; let you &lt;code&gt;plan&lt;/code&gt; a layer before its dependency has ever been applied — Terragrunt feeds fake IDs so the plan can render. Handy. But look closely at that allow-list: &lt;code&gt;["validate", "plan"]&lt;/code&gt;. &lt;strong&gt;No &lt;code&gt;"init"&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That omission is the gotcha, and it ties straight back to the timeout saga from Parts 3 and 4. On a brand-new tree, the very first &lt;code&gt;plan&lt;/code&gt; runs an &lt;code&gt;init&lt;/code&gt; first — and because &lt;code&gt;init&lt;/code&gt; isn't in the mock allow-list, Terragrunt refuses to mock the dependency and instead tries to read &lt;em&gt;real&lt;/em&gt; outputs from a state that doesn't exist yet. Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Error: detected no outputs from dependency ".../instance-pool"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your clean-looking layer fails to plan not because it's wrong, but because the thing &lt;em&gt;upstream&lt;/em&gt; of it was never applied. Some layers in the repo (&lt;code&gt;workspace-assignment&lt;/code&gt;, &lt;code&gt;unity-catalog&lt;/code&gt;) deliberately include &lt;code&gt;"init"&lt;/code&gt; in the allow-list to dodge this; the compute layers don't. The practical consequence is the same either way, and it's the whole reason for the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apply order is not a suggestion
&lt;/h2&gt;

&lt;p&gt;Because of the dependency chain above, &lt;strong&gt;you apply in order.&lt;/strong&gt; Not "usually." Always, the first time through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Workspace line:  workspace → instance-pool → cluster-policy → compute
                 → workspace-assignment → acls

Unity Catalog:   unity-catalog → external-location → catalogs
                 → schemas → groups → grants
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule underneath both lines is the same: &lt;strong&gt;apply a layer only after everything it depends on is already applied.&lt;/strong&gt; Mocks let you &lt;em&gt;plan&lt;/em&gt; out of order, but the &lt;em&gt;first real apply&lt;/em&gt; has to walk the chain, because a mock pool ID doesn't create a cluster and a mock catalog doesn't hold a grant.&lt;/p&gt;

&lt;p&gt;Two failure modes we hit repeatedly, both just ordering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cluster-policy&lt;/code&gt; / &lt;code&gt;compute&lt;/code&gt; plan fails with "detected no outputs" → the pool/policy above it wasn't applied. Apply upstream first.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;workspace-assignment&lt;/code&gt; plans to &lt;code&gt;assignments = {}&lt;/code&gt; → the &lt;code&gt;groups&lt;/code&gt; layer wasn't applied, so there's nothing to assign. Apply &lt;code&gt;groups&lt;/code&gt; first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither is a bug. Both are the dependency graph telling you that you skipped a step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Atlantis ties the bow
&lt;/h2&gt;

&lt;p&gt;We don't run &lt;code&gt;terragrunt apply&lt;/code&gt; from laptops. Every change is a merge request; Atlantis runs &lt;code&gt;atlantis plan&lt;/code&gt; on the MR and &lt;code&gt;atlantis apply&lt;/code&gt; once it's approved. The plan output lands in the MR as a comment, so the diff &lt;em&gt;and&lt;/em&gt; its consequences are reviewable in one place. Account credentials live server-side on the Atlantis host, not in anyone's shell — which also means local &lt;code&gt;plan&lt;/code&gt; is optional (and honestly, usually skipped) since you get the real plan from the MR anyway.&lt;/p&gt;

&lt;p&gt;Put together, the daily loop is small: edit a YAML value, open an MR, read the Atlantis plan, apply. The modules, the env-prefixing, the provider injection, the dependency graph — all of that is machinery you set up once and then mostly forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  The series, in one breath
&lt;/h2&gt;

&lt;p&gt;That's the whole platform:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Building the platform&lt;/strong&gt; — workspaces, account, Unity Catalog metastore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RBAC&lt;/strong&gt; — function-role groups and workspace assignment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute governance&lt;/strong&gt; — instance pools → cluster policies → clusters, and the guardrails around them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The BOOTSTRAP_TIMEOUT mystery&lt;/strong&gt; — a healthy EC2 node that couldn't phone home, traced across three accounts to a firewall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PrivateLink&lt;/strong&gt; — taking the control-plane traffic off the internet without touching the VPC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This&lt;/strong&gt; — the Terraform structure holding all of it together.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If there's one throughline, it's that the boring parts — naming discipline, apply order, who-writes-what — are exactly the parts that decide whether a platform stays sane at the tenth workspace. Get the structure right and the interesting problems (like a packet dying in a firewall) are the only ones left to solve.&lt;/p&gt;

&lt;p&gt;Thanks for reading all six. If you're just arriving, Part 1 is where the estate gets built from nothing.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>terragrunt</category>
      <category>databricks</category>
      <category>aws</category>
    </item>
    <item>
      <title>[Databricks on AWS #5] Fixing Databricks BOOTSTRAP_TIMEOUT with AWS PrivateLink: Control Plane Over the Backbone, Zero New Subnets</title>
      <dc:creator>duke</dc:creator>
      <pubDate>Thu, 02 Jul 2026 01:50:20 +0000</pubDate>
      <link>https://dev.to/javaking1129/databricks-on-aws-5-fixing-databricks-bootstraptimeout-with-aws-privatelink-control-plane-over-pm9</link>
      <guid>https://dev.to/javaking1129/databricks-on-aws-5-fixing-databricks-bootstraptimeout-with-aws-privatelink-control-plane-over-pm9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;📚 Series: Databricks on AWS (Part 5)&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Building a Databricks AI Platform on AWS&lt;/li&gt;
&lt;li&gt;RBAC with Function-Role Groups&lt;/li&gt;
&lt;li&gt;Compute Governance: Pools, Policies, Clusters&lt;/li&gt;
&lt;li&gt;The BOOTSTRAP_TIMEOUT Mystery&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixing It with AWS PrivateLink&lt;/strong&gt; ← you are here&lt;/li&gt;
&lt;li&gt;How We Structure the Terraform&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In Part 4 we traced a &lt;code&gt;BOOTSTRAP_TIMEOUT&lt;/code&gt; all the way to a centralized egress firewall that silently dropped our new workspace CIDR. Here's the clean fix — take the control-plane traffic off the internet entirely, without touching the existing VPC.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In &lt;a href="//./04-the-bootstrap-timeout-mystery.md"&gt;Part 4&lt;/a&gt; we found the culprit: cluster nodes with no public IP were trying to phone home to the Databricks control plane and secure cluster connectivity (SCC) relay, and a centralized inspection firewall was dropping the packets because our new workspace CIDR wasn't in its allow-list. We could have just added a firewall rule and moved on. Instead we did the thing our security team actually wanted from the start: get that traffic off the public internet completely.&lt;/p&gt;

&lt;p&gt;The tool for that is &lt;strong&gt;AWS PrivateLink&lt;/strong&gt;. And the interesting part isn't PrivateLink itself — it's that we landed it with &lt;strong&gt;zero new subnets, zero new CIDR, and zero routing changes&lt;/strong&gt;, in a VPC that was already full.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why VPC endpoints alone don't fix it (the trap from Part 4)
&lt;/h2&gt;

&lt;p&gt;The obvious instinct is: "no internet — just put everything on VPC endpoints." That instinct is correct, but only for &lt;strong&gt;AWS services&lt;/strong&gt;. S3, STS, and Kinesis all publish &lt;code&gt;com.amazonaws...&lt;/code&gt; endpoint services, so you can ride the AWS backbone to them with gateway or interface endpoints.&lt;/p&gt;

&lt;p&gt;The thing that was actually blocked in Part 4 is different: it's the &lt;strong&gt;Databricks control plane and the SCC relay&lt;/strong&gt;. That's &lt;em&gt;Databricks-owned&lt;/em&gt; infrastructure, not an AWS service. There is no &lt;code&gt;com.amazonaws...&lt;/code&gt; endpoint for it that you get for free. Databricks does, however, publish its own PrivateLink endpoint services — you just have to explicitly wire them up.&lt;/p&gt;

&lt;p&gt;So the real fix splits in two:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Traffic&lt;/th&gt;
&lt;th&gt;Solution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S3 / STS / Kinesis&lt;/td&gt;
&lt;td&gt;AWS VPC endpoints (optional, separate)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control plane REST API + SCC relay&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Databricks PrivateLink&lt;/strong&gt; — this is the one that unblocks bootstrap&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This post is about that second row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back-end vs front-end PrivateLink
&lt;/h2&gt;

&lt;p&gt;Databricks PrivateLink comes in two flavors, and you need to know which one you're solving for:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Connection&lt;/th&gt;
&lt;th&gt;Us&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Back-end&lt;/strong&gt; (compute plane)&lt;/td&gt;
&lt;td&gt;cluster → workspace core services: &lt;strong&gt;REST API + SCC relay&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;✅ &lt;strong&gt;required&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Front-end (inbound)&lt;/td&gt;
&lt;td&gt;users → workspace over a private path&lt;/td&gt;
&lt;td&gt;optional (we skipped it)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;BOOTSTRAP_TIMEOUT&lt;/code&gt; is purely a back-end problem: the compute plane can't reach core services. Front-end PrivateLink is about how &lt;em&gt;users&lt;/em&gt; reach the workspace UI — a different concern, and one we deliberately left alone so that people can still log in normally over the internet.&lt;/p&gt;

&lt;p&gt;Back-end PrivateLink means &lt;strong&gt;two interface VPC endpoints&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one to the &lt;strong&gt;workspace / REST API&lt;/strong&gt; endpoint service&lt;/li&gt;
&lt;li&gt;one to the &lt;strong&gt;SCC relay&lt;/strong&gt; endpoint service&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are required. Skip one and you break either the REST channel or the relay channel — the cluster still won't come up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The elegant part: no new subnets
&lt;/h2&gt;

&lt;p&gt;Here's the constraint we walked into. The VPC was a &lt;code&gt;/24&lt;/code&gt; and it was &lt;strong&gt;full&lt;/strong&gt; — no room to carve out a fresh &lt;code&gt;/28&lt;/code&gt; for endpoints. On most PrivateLink walkthroughs step one is "create a subnet for your endpoints." We couldn't.&lt;/p&gt;

&lt;p&gt;Then the realization: &lt;strong&gt;an interface VPC endpoint is just an ENI with a private IP.&lt;/strong&gt; It doesn't need its own subnet. You can drop its ENI straight into an &lt;strong&gt;existing&lt;/strong&gt; subnet — including the very cluster subnets your compute already runs in. Each endpoint consumes a handful of private IPs (roughly 4, one per AZ ENI plus overhead) and that's it.&lt;/p&gt;

&lt;p&gt;So the plan became: put both endpoints' ENIs in the existing cluster subnets. No new subnet, no new CIDR. Just ~4 IPs out of the dozens the subnets had free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why there's no routing change either
&lt;/h2&gt;

&lt;p&gt;This is the part that made the security review easy, so it's worth being precise about &lt;em&gt;why&lt;/em&gt; it's true.&lt;/p&gt;

&lt;p&gt;An interface endpoint is reachable at a &lt;strong&gt;private IP inside your VPC&lt;/strong&gt;. When a cluster node talks to that IP, it's talking to something in its own VPC — which every route table already handles via the implicit &lt;strong&gt;&lt;code&gt;local&lt;/code&gt; route&lt;/strong&gt;. There is nothing to add. No &lt;code&gt;0.0.0.0/0&lt;/code&gt; change, no new prefix, no target rewrite.&lt;/p&gt;

&lt;p&gt;Contrast this with an &lt;strong&gt;S3 gateway endpoint&lt;/strong&gt;, which &lt;em&gt;does&lt;/em&gt; modify routing — a gateway endpoint installs a prefix-list route (&lt;code&gt;pl-xxxx → vpce-xxxx&lt;/code&gt;) into your route tables. That's a real routing change you have to review. Interface endpoints don't work that way. They work via &lt;strong&gt;DNS plus the local route&lt;/strong&gt;, not route-table entries.&lt;/p&gt;

&lt;p&gt;Which brings us to the mechanism that actually redirects the traffic:&lt;/p&gt;

&lt;h2&gt;
  
  
  Private DNS is what redirects the traffic
&lt;/h2&gt;

&lt;p&gt;When you enable &lt;strong&gt;private DNS&lt;/strong&gt; on the interface endpoints, AWS overrides DNS resolution &lt;em&gt;inside your VPC&lt;/em&gt; so that the Databricks domains resolve to the endpoints' private ENI IPs instead of public addresses.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;tunnel.&amp;lt;region&amp;gt;.cloud.databricks.com&lt;/code&gt; — the SCC relay host that was getting dropped at the firewall in Part 4 — now resolves to a private IP in your own cluster subnet. The node connects to that private IP, hits the endpoint ENI, and the traffic rides the AWS backbone straight to Databricks' endpoint service. It never leaves AWS's network. It never touches the firewall.&lt;/p&gt;

&lt;p&gt;And critically: private DNS only rewrites the &lt;strong&gt;Databricks&lt;/strong&gt; domains. Every other service in the VPC keeps resolving exactly as before, because they don't use those hostnames. That's why the blast radius here is essentially zero — the existing (non-Databricks) workloads are completely untouched.&lt;/p&gt;

&lt;p&gt;To summarize the "why nothing else breaks" story:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Thing&lt;/th&gt;
&lt;th&gt;Change?&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Route tables&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;interface endpoints use DNS + &lt;code&gt;local&lt;/code&gt;, not routes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Existing non-Databricks services&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;private DNS only rewrites Databricks domains&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subnets / CIDR&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;ENIs go into existing subnets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Existing SGs / NACLs&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;a new SG is created just for the endpoints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The only real change&lt;/td&gt;
&lt;td&gt;~4 private IPs consumed + workspaces flipped to PrivateLink&lt;/td&gt;
&lt;td&gt;non-disruptive&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The AWS side
&lt;/h2&gt;

&lt;p&gt;Concretely, the infra team does three small things — and notably, &lt;strong&gt;no new subnet, no new CIDR, no route change&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One security group&lt;/strong&gt; for the endpoints, allowing inbound &lt;code&gt;443&lt;/code&gt; (and the relay port) &lt;em&gt;from the cluster security group&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two interface VPC endpoints&lt;/strong&gt;, both placed in the existing cluster subnets (one per AZ), with &lt;strong&gt;private DNS enabled&lt;/strong&gt;, pointed at the two Databricks endpoint services. In &lt;code&gt;&amp;lt;region&amp;gt;&lt;/code&gt; those services are:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;| Endpoint | Service name |&lt;br&gt;
   |----------|--------------|&lt;br&gt;
   | Workspace (REST API) | &lt;code&gt;com.amazonaws.vpce.&amp;lt;region&amp;gt;.vpce-svc-...&lt;/code&gt; |&lt;br&gt;
   | SCC relay | &lt;code&gt;com.amazonaws.vpce.&amp;lt;region&amp;gt;.vpce-svc-...&lt;/code&gt; |&lt;/p&gt;

&lt;p&gt;(The exact &lt;code&gt;vpce-svc-...&lt;/code&gt; values come from the Databricks region docs — always re-verify them right before applying, and use the console's "Verify service" to confirm they resolve.)&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Hand back the &lt;strong&gt;two VPC endpoint IDs&lt;/strong&gt; (&lt;code&gt;vpce-...&lt;/code&gt;) for the Databricks-side registration.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the whole AWS footprint. A security group and two ENIs.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Databricks side (Terraform)
&lt;/h2&gt;

&lt;p&gt;Now Databricks needs to know these endpoints exist and be told to route the workspace through them. Three resource types:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Register each endpoint&lt;/strong&gt; with the Databricks account, twice — once for the relay, once for the REST API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"databricks_mws_vpc_endpoint"&lt;/span&gt; &lt;span class="s2"&gt;"relay"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;provider&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;databricks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mws&lt;/span&gt;
  &lt;span class="nx"&gt;account_id&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;databricks-account-id&amp;gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;aws_vpc_endpoint_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpce-...relay"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_endpoint_name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"our-dev-relay-vpce"&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;region&amp;gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"databricks_mws_vpc_endpoint"&lt;/span&gt; &lt;span class="s2"&gt;"rest"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;provider&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;databricks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mws&lt;/span&gt;
  &lt;span class="nx"&gt;account_id&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;databricks-account-id&amp;gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;aws_vpc_endpoint_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpce-...workspace"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_endpoint_name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"our-dev-workspace-vpce"&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;region&amp;gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Create private access settings&lt;/strong&gt; — and this is the one setting people get wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"databricks_mws_private_access_settings"&lt;/span&gt; &lt;span class="s2"&gt;"pas"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;provider&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;databricks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mws&lt;/span&gt;
  &lt;span class="nx"&gt;private_access_settings_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"our-dev-pas"&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;region&amp;gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;public_access_enabled&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;public_access_enabled = true&lt;/code&gt; is deliberate. We're doing &lt;strong&gt;back-end&lt;/strong&gt; PrivateLink only — the compute plane goes private, but we want &lt;strong&gt;users to keep logging in over the internet&lt;/strong&gt;. Set this to &lt;code&gt;false&lt;/code&gt; and you've quietly switched on front-end lockdown too, and your users get locked out of the UI. Keep it &lt;code&gt;true&lt;/code&gt; unless you're also doing front-end PrivateLink on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Attach the endpoints to the network and the workspaces.&lt;/strong&gt; The network config gets the two endpoints wired into &lt;code&gt;vpc_endpoints { dataplane_relay = [...], rest_api = [...] }&lt;/code&gt;, and the workspaces get &lt;code&gt;private_access_settings_id&lt;/code&gt; and the network. Note: &lt;code&gt;subnet_ids&lt;/code&gt; stays exactly as it was — we are &lt;strong&gt;not&lt;/strong&gt; changing the cluster subnets, only adding the endpoint references.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas (the ones that cost real time)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need both endpoints.&lt;/strong&gt; Workspace/REST &lt;em&gt;and&lt;/em&gt; relay. One alone silently half-works and the cluster still fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private DNS is mandatory.&lt;/strong&gt; Forget to enable it and the Databricks domains keep resolving to public IPs — the endpoints exist but nothing uses them. This is the single most common "I set it up but it still times out" cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep &lt;code&gt;public_access_enabled = true&lt;/code&gt;&lt;/strong&gt; for back-end-only PrivateLink, or you'll lock users out of the console.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait ~20 minutes&lt;/strong&gt; after the workspace flips to PrivateLink before you launch a test cluster. The registration and DNS propagation aren't instant, and an early test will look like a failure that isn't one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not SSL-decrypt the relay.&lt;/strong&gt; Same certificate-pinning gotcha from Part 4 — if any inspection sits in front of it and forward-proxy decrypts, the relay breaks. With PrivateLink the traffic bypasses the firewall entirely, but if you still have belt-and-suspenders inspection anywhere in the path, exclude the Databricks domains.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;The proof is three checks, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Workspace shows &lt;code&gt;RUNNING&lt;/code&gt;, then wait ~20 minutes.&lt;/li&gt;
&lt;li&gt;From inside the VPC, &lt;code&gt;nslookup tunnel.&amp;lt;region&amp;gt;.cloud.databricks.com&lt;/code&gt; → it should resolve to a &lt;strong&gt;private endpoint IP&lt;/strong&gt; in your cluster subnet, not a public address.&lt;/li&gt;
&lt;li&gt;Launch a cluster. It reaches &lt;code&gt;RUNNING&lt;/code&gt;. &lt;code&gt;BOOTSTRAP_TIMEOUT&lt;/code&gt; is gone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. The same cluster that spent 11 minutes stuck in &lt;code&gt;INSTANCE_INITIALIZING&lt;/code&gt; in Part 4 now comes up cleanly — over the backbone, off the internet, with the rest of the VPC none the wiser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;VPC endpoints solve S3/STS/Kinesis. The &lt;strong&gt;control plane + relay&lt;/strong&gt; are Databricks-owned — for those you need &lt;strong&gt;PrivateLink&lt;/strong&gt;, and that's specifically what fixes &lt;code&gt;BOOTSTRAP_TIMEOUT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Back-end PrivateLink = &lt;strong&gt;two interface endpoints&lt;/strong&gt; (workspace/REST + SCC relay). Both required.&lt;/li&gt;
&lt;li&gt;An interface endpoint is just an &lt;strong&gt;ENI with a private IP&lt;/strong&gt; — it fits into an existing subnet, so &lt;strong&gt;no new subnet, no new CIDR, and no routing change&lt;/strong&gt;. (Contrast S3 &lt;em&gt;gateway&lt;/em&gt; endpoints, which do add a route.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private DNS&lt;/strong&gt; is the actual redirect mechanism, and it only touches the Databricks domains — so existing services are untouched.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;public_access_enabled = true&lt;/code&gt; for back-end-only, wait 20 minutes, and never decrypt the relay.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next up: how all of this is actually structured in Terraform — the module layout, the dual Databricks providers (&lt;code&gt;mws&lt;/code&gt; vs workspace), and the appendix that ties the whole series together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next:&lt;/strong&gt; &lt;em&gt;Part 6 — The Terraform structure behind it all (and a series appendix).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>databricks</category>
      <category>aws</category>
      <category>privatelink</category>
      <category>networking</category>
    </item>
    <item>
      <title>[Databricks on AWS #4] The BOOTSTRAP_TIMEOUT Mystery: Tracing a Databricks Cluster from Data Plane to Control Plane (Transit Gateway + Firewall)</title>
      <dc:creator>duke</dc:creator>
      <pubDate>Thu, 02 Jul 2026 01:30:12 +0000</pubDate>
      <link>https://dev.to/javaking1129/databricks-on-aws-4-the-bootstraptimeout-mystery-tracing-a-databricks-cluster-from-data-plane-4lem</link>
      <guid>https://dev.to/javaking1129/databricks-on-aws-4-the-bootstraptimeout-mystery-tracing-a-databricks-cluster-from-data-plane-4lem</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;📚 Series: Databricks on AWS (Part 4)&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Building a Databricks AI Platform on AWS&lt;/li&gt;
&lt;li&gt;RBAC with Function-Role Groups&lt;/li&gt;
&lt;li&gt;Compute Governance: Pools, Policies, Clusters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The BOOTSTRAP_TIMEOUT Mystery&lt;/strong&gt; ← you are here&lt;/li&gt;
&lt;li&gt;Fixing It with AWS PrivateLink&lt;/li&gt;
&lt;li&gt;How We Structure the Terraform&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The EC2 nodes were healthy — 3/3 status checks. The cluster still never started. Here's the 11-minute timeout that sent us tracing packets across three AWS accounts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most "my Databricks cluster won't start" posts end with "open port 443." This one didn't — because the firewall &lt;em&gt;did&lt;/em&gt; allow 443. The traffic was dying somewhere else, and the only way to find it was to follow a single packet from the cluster node all the way to the Databricks control plane.&lt;/p&gt;

&lt;p&gt;If you run Databricks classic compute inside a customer-managed VPC behind a centralized egress (Transit Gateway → inspection firewall), this is the failure mode nobody warns you about.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;A Databricks workspace deployment on AWS, classic compute, &lt;strong&gt;customer-managed VPC&lt;/strong&gt;, secure cluster connectivity (no public IPs on cluster nodes). The new workspace reused an existing "spoke" VPC that egresses through a shared network hub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cluster node (no public IP)
  → spoke VPC route table: 0.0.0.0/0 → Transit Gateway
  → TGW (shared network-hub account)
  → DMZ VPC → GWLB inspection firewall → NAT → IGW → internet → Databricks control plane
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instance pools applied. Cluster policies applied. Then the cluster itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;atlantis apply &lt;span class="nt"&gt;-d&lt;/span&gt; .../ws-landing/compute
&lt;span class="go"&gt;databricks_cluster.this["shared_small"]: Still creating... [10m20s elapsed]
&lt;/span&gt;&lt;span class="c"&gt;...
&lt;/span&gt;&lt;span class="go"&gt;Error: cannot create cluster: failed to reach RUNNING, got TERMINATED:
  Self-bootstrap timed out during launch ... BOOTSTRAP_TIMEOUT
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;~25 minutes of "Still creating", then TERMINATED.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom that rules out half the internet
&lt;/h2&gt;

&lt;p&gt;I pulled the cluster event log. The line that mattered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BOOTSTRAP_TIMEOUT: [id: InstanceId(i-xxxx), status: INSTANCE_INITIALIZING, ...]
timed out after 704524 milliseconds. AWS bootstrap diagnostic output could not be fetched.
Please check network connectivity from the data plane to the control plane.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things narrowed it down fast:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The EC2 nodes were up and healthy&lt;/strong&gt; — 3/3 status checks in the AWS console, no public IP (as expected for secure cluster connectivity).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;INSTANCE_INITIALIZING&lt;/code&gt; + "diagnostic output could not be fetched"&lt;/strong&gt; — the control plane couldn't even reach the node to pull logs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Healthy EC2 + cluster never reaches RUNNING = the node booted but &lt;strong&gt;couldn't phone home&lt;/strong&gt;. Not capacity. Not IAM. Not Terraform. Pure egress: the node never opened its outbound tunnel to the control plane's secure cluster connectivity relay.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Databricks' own error literally says &lt;em&gt;"check network connectivity from the data plane to the control plane."&lt;/em&gt; Believe it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why "just use VPC endpoints" doesn't fix this
&lt;/h2&gt;

&lt;p&gt;The first instinct (and our security team's) was: route everything through VPC endpoints, no internet. That's correct &lt;strong&gt;for AWS services&lt;/strong&gt; — S3, STS, Kinesis can all ride the AWS backbone via gateway/interface endpoints.&lt;/p&gt;

&lt;p&gt;But the thing that's actually blocked is the &lt;strong&gt;Databricks control plane and the secure cluster connectivity relay&lt;/strong&gt;. Those are &lt;em&gt;Databricks-owned&lt;/em&gt; infrastructure, not an AWS service — there is no &lt;code&gt;com.amazonaws...&lt;/code&gt; endpoint for them. Your options are exactly two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;allow outbound to the control plane (egress firewall), or&lt;/li&gt;
&lt;li&gt;AWS PrivateLink to Databricks (backbone, but you still explicitly wire it).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no configuration where the node simply doesn't talk to the control plane. With secure cluster connectivity, the node has &lt;strong&gt;no public IP and no open inbound ports&lt;/strong&gt; — so the control plane &lt;em&gt;cannot&lt;/em&gt; initiate inward. The node has to go outbound — block that path and the cluster simply can't come up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracing the path
&lt;/h2&gt;

&lt;p&gt;Routing first. I walked every hop and confirmed each route table — including the new workspace CIDR — was correct end to end:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;hop&lt;/th&gt;
&lt;th&gt;what to check&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;spoke subnet RT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.0.0.0/0 → TGW&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TGW route table&lt;/td&gt;
&lt;td&gt;default → DMZ VPC; return → spoke (propagated)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DMZ ingress subnet&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0.0.0.0/0 → GWLB endpoint&lt;/code&gt; (inspection)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DMZ GWLB subnet&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0.0.0.0/0 → NAT&lt;/code&gt;; spoke CIDR → TGW (return)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DMZ public subnet&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0.0.0.0/0 → IGW&lt;/code&gt;; spoke CIDR → GWLB (return)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every route — outbound and return — was present, and the new CIDR was wired identically to the existing workspaces that worked. So routing wasn't it.&lt;/p&gt;

&lt;p&gt;That left exactly one thing in the path that &lt;em&gt;isn't&lt;/em&gt; a route: the &lt;strong&gt;inspection firewall behind the Gateway Load Balancer&lt;/strong&gt;. The traffic reached the firewall and got dropped there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually broke
&lt;/h2&gt;

&lt;p&gt;The DMZ runs a centralized egress firewall (think Palo Alto / appliance behind a GWLB). It allow-lists by &lt;strong&gt;source CIDR and destination&lt;/strong&gt;. The existing workspaces lived in older CIDR ranges that were already in the allow policy. Our &lt;strong&gt;new workspace CIDR was not&lt;/strong&gt; — so its packets to the Databricks control plane hit the firewall and were silently dropped.&lt;/p&gt;

&lt;p&gt;Healthy EC2, perfect routing, and a firewall that quietly eats the SYN. That's why the node sat in &lt;code&gt;INSTANCE_INITIALIZING&lt;/code&gt; for 11 minutes and the control plane never heard from it.&lt;/p&gt;

&lt;p&gt;The confirmation is in the firewall logs: filter by the new source CIDR and you see DENY entries to the Databricks relay on 443.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Databricks outbound you actually need
&lt;/h2&gt;

&lt;p&gt;For the data plane to bootstrap (region &lt;code&gt;&amp;lt;region&amp;gt;&lt;/code&gt; here; check the docs for yours), the node needs to reach:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;destination&lt;/th&gt;
&lt;th&gt;port&lt;/th&gt;
&lt;th&gt;why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tunnel.&amp;lt;region&amp;gt;.cloud.databricks.com&lt;/code&gt; (control plane CIDR, e.g. &lt;code&gt;3.x.x.x/28&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;443&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;secure cluster connectivity relay&lt;/strong&gt; (the one that was blocked)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;control plane / web app&lt;/td&gt;
&lt;td&gt;443&lt;/td&gt;
&lt;td&gt;registration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;regional metastore (RDS)&lt;/td&gt;
&lt;td&gt;3306&lt;/td&gt;
&lt;td&gt;cluster launch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 / STS / Kinesis (regional)&lt;/td&gt;
&lt;td&gt;443&lt;/td&gt;
&lt;td&gt;runtime, credentials, logs — &lt;strong&gt;put these on VPC endpoints&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One Palo Alto gotcha worth its own line: if you do SSL forward-proxy decryption, &lt;strong&gt;exclude the Databricks domains&lt;/strong&gt;. The relay uses certificate pinning; decrypt it and it breaks even with the allow rule in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;BOOTSTRAP_TIMEOUT&lt;/code&gt; + healthy EC2 + "diagnostic output could not be fetched" = &lt;strong&gt;data-plane → control-plane egress is blocked&lt;/strong&gt;, full stop. Don't go hunting IAM or Terraform.&lt;/li&gt;
&lt;li&gt;Secure cluster connectivity means the node has no inbound path — it &lt;em&gt;must&lt;/em&gt; egress to the relay. That egress is not optional.&lt;/li&gt;
&lt;li&gt;VPC endpoints solve S3/STS/Kinesis, &lt;strong&gt;not&lt;/strong&gt; the control plane/relay. Those are Databricks-owned; allow them or use PrivateLink.&lt;/li&gt;
&lt;li&gt;When you add a new workspace CIDR behind a centralized firewall, the firewall policy is the thing everyone forgets. New CIDR ≠ automatically allowed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The clean long-term fix is to take the control-plane traffic off the internet entirely with AWS PrivateLink — without touching the existing VPC. That's Part 5.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next:&lt;/strong&gt; &lt;em&gt;Fixing it with AWS PrivateLink — control-plane over the backbone, zero new subnets, zero routing changes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>databricks</category>
      <category>aws</category>
      <category>networking</category>
      <category>terraform</category>
    </item>
    <item>
      <title>[Databricks on AWS #3] Compute Governance on Databricks: Instance Pools, Cluster Policies, and Shared Clusters</title>
      <dc:creator>duke</dc:creator>
      <pubDate>Thu, 02 Jul 2026 01:29:50 +0000</pubDate>
      <link>https://dev.to/javaking1129/databricks-on-aws-3-compute-governance-on-databricks-instance-pools-cluster-policies-and-848</link>
      <guid>https://dev.to/javaking1129/databricks-on-aws-3-compute-governance-on-databricks-instance-pools-cluster-policies-and-848</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;📚 Series: Databricks on AWS (Part 3)&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Building a Databricks AI Platform on AWS&lt;/li&gt;
&lt;li&gt;RBAC with Function-Role Groups&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute Governance: Pools, Policies, Clusters&lt;/strong&gt; ← you are here&lt;/li&gt;
&lt;li&gt;The BOOTSTRAP_TIMEOUT Mystery&lt;/li&gt;
&lt;li&gt;Fixing It with AWS PrivateLink&lt;/li&gt;
&lt;li&gt;How We Structure the Terraform&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;RBAC decides &lt;em&gt;who&lt;/em&gt; a user is. Compute governance decides &lt;em&gt;what hardware they're allowed to spin up&lt;/em&gt;. Here's how instance pools, cluster policies, and an entitlement gate turn "anyone can launch a 128-core GPU box" into "you get exactly what your role permits."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In Part 2 we built the RBAC model: users map to function roles, function roles map to access roles, access roles get grants. That controls which &lt;em&gt;data&lt;/em&gt; someone can touch.&lt;/p&gt;

&lt;p&gt;But there's a second axis nobody talks about until the cloud bill arrives: &lt;strong&gt;compute&lt;/strong&gt;. Left ungoverned, a single curious analyst can launch a cluster of &lt;code&gt;r6i.4xlarge&lt;/code&gt; nodes (16 cores, 128 GB, each) at 2 a.m., forget to turn it off, and greet you with a five-figure surprise on Monday. Governance is the answer, and on Databricks it comes in three layers.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three layers, top to bottom
&lt;/h2&gt;

&lt;p&gt;Think of it as a funnel. Each layer narrows what a user can actually do:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Who feels it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Instance pool&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pre-warmed VMs waiting to be claimed — faster cluster starts&lt;/td&gt;
&lt;td&gt;Everyone (transparently)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cluster policy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The &lt;em&gt;rules&lt;/em&gt;: which instance types, sizes, autotermination, runtime&lt;/td&gt;
&lt;td&gt;Engineers creating clusters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Entitlement gate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;allow_cluster_create&lt;/code&gt; on/off per group&lt;/td&gt;
&lt;td&gt;Non-admins (blocked)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And on top of all that: &lt;strong&gt;shared, pre-made clusters&lt;/strong&gt; for the people who shouldn't be creating anything at all.&lt;/p&gt;

&lt;p&gt;Let's go layer by layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 1: Instance Pools — pre-warmed VMs
&lt;/h2&gt;

&lt;p&gt;A cold cluster start on AWS means: request EC2 capacity → wait for the instances → install the Databricks runtime → join the cluster. That's several minutes of a user staring at a spinner.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;instance pool&lt;/strong&gt; keeps a set of VMs pre-acquired (or ready to be acquired fast) so clusters attach to them instead of provisioning from scratch. It's purely about speed and cost predictability — a pool doesn't &lt;em&gt;restrict&lt;/em&gt; anything on its own.&lt;/p&gt;

&lt;p&gt;For our workspace we defined six pools, split by CPU/GPU and by size:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pool&lt;/th&gt;
&lt;th&gt;Instance type&lt;/th&gt;
&lt;th&gt;Capacity&lt;/th&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ip_cpu_small&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;m6g.large (2 vCPU / 8 GB)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;cpu/small&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ip_cpu_medium&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;m6g.xlarge (4 vCPU / 16 GB)&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;cpu/medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ip_cpu_large&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;r6i.2xlarge (8 vCPU / 64 GB)&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;cpu/large&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ip_cpu_xlarge&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;r6i.4xlarge (16 vCPU / 128 GB)&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;cpu/xlarge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ip_gpu_small&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;g5.xlarge (1× A10G)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;gpu/small&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ip_gpu_large&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;g5.2xlarge (1× A10G)&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;gpu/large&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The important knobs are shared across all of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;min_idle_instances = 0&lt;/code&gt; — we don't pay to keep VMs warm 24/7; the pool spins up on demand and the &lt;em&gt;first&lt;/em&gt; start after idle pays the cold-start tax. A tradeoff we accepted for cost.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;idle_instance_autotermination_minutes = 10&lt;/code&gt; — idle VMs release themselves after 10 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;availability = ON_DEMAND&lt;/code&gt; — no spot reclaim surprises for interactive work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;custom_tags&lt;/code&gt; matter more than they look: they flow into AWS cost allocation, so you can answer "how much did the GPU pools cost this month?" without guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: Cluster Policies — the actual governance
&lt;/h2&gt;

&lt;p&gt;Instance pools make clusters fast. &lt;strong&gt;Cluster policies make clusters legal.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A policy is a template that constrains what a cluster &lt;em&gt;can&lt;/em&gt; be: which pool it draws from, which Spark runtime, min/max workers, autotermination, and how many clusters one user can create. A user with a policy can only create clusters that fit inside it — they can't override the instance type, can't disable autotermination, can't ask for 200 workers.&lt;/p&gt;

&lt;p&gt;Our analytics workspace has eight policies. A representative slice:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Policy&lt;/th&gt;
&lt;th&gt;Pool (worker / driver)&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th&gt;Workers&lt;/th&gt;
&lt;th&gt;Autoterm (min)&lt;/th&gt;
&lt;th&gt;Max clusters/user&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cp_cpu_small&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ip_cpu_small / ip_cpu_small&lt;/td&gt;
&lt;td&gt;14.3.x-scala2.12&lt;/td&gt;
&lt;td&gt;1–4&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cp_cpu_medium&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ip_cpu_medium / ip_cpu_medium&lt;/td&gt;
&lt;td&gt;14.3.x-scala2.12&lt;/td&gt;
&lt;td&gt;0–8&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cp_cpu_large&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ip_cpu_large / ip_cpu_medium&lt;/td&gt;
&lt;td&gt;14.3.x-scala2.12&lt;/td&gt;
&lt;td&gt;0–12&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cp_gpu_small&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ip_gpu_small / ip_cpu_small&lt;/td&gt;
&lt;td&gt;14.3.x-gpu-ml-scala2.12&lt;/td&gt;
&lt;td&gt;0–8&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cp_job_standard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ip_cpu_medium / ip_cpu_medium&lt;/td&gt;
&lt;td&gt;14.3.x-scala2.12&lt;/td&gt;
&lt;td&gt;0–16&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few design choices worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Autotermination is not optional.&lt;/strong&gt; The policy sets it (10 min for interactive, 30 for jobs) and the user can't remove it. This single rule kills the "forgot to shut it down over the weekend" bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;data_security_mode = USER_ISOLATION&lt;/code&gt;&lt;/strong&gt; across the board — Unity Catalog enforcement stays on. Governance from Part 2 doesn't get bypassed by a cleverly configured cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Driver often runs on a smaller pool than workers&lt;/strong&gt; (e.g. &lt;code&gt;cp_cpu_large&lt;/code&gt; uses &lt;code&gt;ip_cpu_large&lt;/code&gt; workers but an &lt;code&gt;ip_cpu_medium&lt;/code&gt; driver). The driver rarely needs to match worker muscle, and this trims cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;max_clusters_per_user&lt;/code&gt;&lt;/strong&gt; caps sprawl. The big policies are limited to 1 cluster per person.&lt;/li&gt;
&lt;li&gt;A shared &lt;code&gt;cost_center&lt;/code&gt; tag on every policy feeds the same cost-allocation story as the pools.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Job policies (higher worker ceilings, longer autotermination) live alongside the interactive ones, so batch pipelines get their own lane without borrowing the interactive budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: The Entitlement Gate — &lt;code&gt;allow_cluster_create&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Policies govern &lt;em&gt;how&lt;/em&gt; someone creates a cluster. But some people shouldn't create clusters at all.&lt;/p&gt;

&lt;p&gt;Databricks has a workspace-level &lt;strong&gt;entitlement&lt;/strong&gt; for exactly this: &lt;code&gt;allow_cluster_create&lt;/code&gt;. Turn it &lt;strong&gt;off&lt;/strong&gt; for a group, and members of that group physically cannot create clusters — the button is gone, the API call is rejected. It doesn't matter what policies exist; the door is locked before they reach it.&lt;/p&gt;

&lt;p&gt;This is the gate that makes the whole role model coherent:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;&lt;code&gt;allow_cluster_create&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Cluster policies&lt;/th&gt;
&lt;th&gt;What they can do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Admin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;all / unrestricted&lt;/td&gt;
&lt;td&gt;Create anything. Break glass.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Engineer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;assigned policies only&lt;/td&gt;
&lt;td&gt;Create clusters — but only inside their policy's box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Analyst&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;off&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;No creation at all — only attach to pre-made shared clusters&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An engineer gets freedom &lt;em&gt;within guardrails&lt;/em&gt;. An analyst gets no guardrails because they get no steering wheel — they use what's already there. And that "what's already there" is the last piece.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared clusters — for the people who can't create
&lt;/h2&gt;

&lt;p&gt;If analysts can't create clusters, they need clusters waiting for them. So the analytics workspace runs a couple of always-available &lt;strong&gt;shared, all-purpose clusters&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cluster&lt;/th&gt;
&lt;th&gt;Policy&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cp_shared_small&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;cp_cpu_small&lt;/td&gt;
&lt;td&gt;14.3.x-scala2.12&lt;/td&gt;
&lt;td&gt;m6g.large, 1 driver + 1 worker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cp_shared_medium&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;cp_cpu_medium&lt;/td&gt;
&lt;td&gt;14.3.x-scala2.12&lt;/td&gt;
&lt;td&gt;m6g.xlarge, 1 driver + 0 min workers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are built &lt;em&gt;from&lt;/em&gt; the same policies engineers use, so they inherit the same autotermination and isolation rules. Access is granted per group via ACLs (&lt;code&gt;CAN_USE&lt;/code&gt;, &lt;code&gt;CAN_ATTACH_TO&lt;/code&gt;) — an analyst attaches, runs their notebook, and never touches a provisioning decision.&lt;/p&gt;

&lt;p&gt;The pipeline workspace, by contrast, has &lt;em&gt;no&lt;/em&gt; shared clusters — it's job-driven, so clusters are ephemeral and spun up per run from job policies. Different workspace, different compute personality, same governance primitives.&lt;/p&gt;




&lt;h2&gt;
  
  
  Apply order: pool → policy → compute (and the Terragrunt trap)
&lt;/h2&gt;

&lt;p&gt;Here's where Infrastructure-as-Code bites. The three layers have a hard dependency chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;instance-pool  →  cluster-policy  →  compute (clusters)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Policies reference pool IDs. Clusters reference policy IDs. So you &lt;strong&gt;must&lt;/strong&gt; apply in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. pools first&lt;/span&gt;
atlantis apply &lt;span class="nt"&gt;-d&lt;/span&gt; .../ws-landing/instance-pool
&lt;span class="c"&gt;# 2. re-plan, then policies (they now see real pool IDs)&lt;/span&gt;
atlantis apply &lt;span class="nt"&gt;-d&lt;/span&gt; .../ws-landing/cluster-policy
&lt;span class="c"&gt;# 3. re-plan, then clusters&lt;/span&gt;
atlantis apply &lt;span class="nt"&gt;-d&lt;/span&gt; .../ws-landing/compute
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And now the gotcha that cost us a confused afternoon. On the &lt;strong&gt;very first&lt;/strong&gt; plan — before pools are applied — the policy and compute plans fail with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: ... instance-pool ... is a dependency of ... cluster-policy ...
       but detected no outputs. ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks like a broken config. It isn't. Terragrunt lets a dependency return &lt;em&gt;mock outputs&lt;/em&gt; so downstream modules can plan before the upstream is applied — but only for the Terraform commands you allowlist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;mock_outputs_allowed_terraform_commands&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"validate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"plan"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failure happens during &lt;code&gt;terragrunt init&lt;/code&gt;, and &lt;code&gt;init&lt;/code&gt; &lt;strong&gt;isn't in that list&lt;/strong&gt;. So on a cold bootstrap, &lt;code&gt;init&lt;/code&gt; tries to fetch the &lt;em&gt;real&lt;/em&gt; output of an unapplied pool, finds nothing, and dies. On our older workspaces this never surfaced — those pools were already applied, so real outputs existed.&lt;/p&gt;

&lt;p&gt;Two ways out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Just apply in order&lt;/strong&gt; (pool → re-plan → policy → re-plan → compute). Once the upstream is applied, real outputs exist and the mock is never needed. This is what we did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;"init"&lt;/code&gt;&lt;/strong&gt; to &lt;code&gt;mock_outputs_allowed_terraform_commands&lt;/code&gt; on the policy/compute modules. The mock now covers the init phase too — but you &lt;em&gt;still&lt;/em&gt; apply in order, because a cluster genuinely can't be created against a pool that doesn't exist yet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The lesson: on a greenfield deployment, "detected no outputs" almost always means "you haven't applied the thing upstream yet," not "your dependency block is wrong."&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Three layers, one funnel.&lt;/strong&gt; Pools = speed. Policies = the real governance (instance type, size, autotermination — non-negotiable). Entitlement gate = who's even allowed to create.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roles map cleanly onto the layers.&lt;/strong&gt; Admin is unrestricted, engineer creates &lt;em&gt;within a policy&lt;/em&gt;, analyst creates &lt;em&gt;nothing&lt;/em&gt; and uses shared clusters. &lt;code&gt;allow_cluster_create = off&lt;/code&gt; is what makes the analyst tier real.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autotermination baked into policy&lt;/strong&gt; is the single highest-ROI rule you can ship. It ends surprise weekend bills.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply order is load-bearing.&lt;/strong&gt; pool → policy → compute, and the first cold plan failing with "detected no outputs" is a Terragrunt mock/&lt;code&gt;init&lt;/code&gt; quirk, not a bug in your code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So we applied it. Pools came up — real IDs issued. Policies came up — real IDs issued. Then we ran the compute apply to create those two shared clusters, and...&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;databricks_cluster.this["shared_small"]: Still creating... [10m20s elapsed]
&lt;/span&gt;&lt;span class="c"&gt;...
&lt;/span&gt;&lt;span class="go"&gt;Error: cannot create cluster: failed to reach RUNNING, got TERMINATED:
  Self-bootstrap timed out during launch ... BOOTSTRAP_TIMEOUT
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The EC2 nodes booted. Status checks passed, 3/3. And the clusters refused to start anyway — 25 minutes of "Still creating," then TERMINATED. Not a policy problem. Not an IAM problem. Something in the network was quietly eating our packets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next:&lt;/strong&gt; &lt;em&gt;The BOOTSTRAP_TIMEOUT Mystery — tracing a Databricks cluster from data plane to control plane, across three AWS accounts and one very quiet firewall.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>databricks</category>
      <category>aws</category>
      <category>terraform</category>
      <category>governance</category>
    </item>
    <item>
      <title>[Databricks on AWS #2] RBAC on Databricks: Function-Role Groups, Workspace Assignment, and Why USER/ADMIN Isn't the Whole Story</title>
      <dc:creator>duke</dc:creator>
      <pubDate>Thu, 02 Jul 2026 01:25:08 +0000</pubDate>
      <link>https://dev.to/javaking1129/databricks-on-aws-2-rbac-on-databricks-function-role-groups-workspace-assignment-and-why-1519</link>
      <guid>https://dev.to/javaking1129/databricks-on-aws-2-rbac-on-databricks-function-role-groups-workspace-assignment-and-why-1519</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;📚 Series: Databricks on AWS (Part 2)&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Building a Databricks AI Platform on AWS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RBAC with Function-Role Groups&lt;/strong&gt; ← you are here&lt;/li&gt;
&lt;li&gt;Compute Governance: Pools, Policies, Clusters&lt;/li&gt;
&lt;li&gt;The BOOTSTRAP_TIMEOUT Mystery&lt;/li&gt;
&lt;li&gt;Fixing It with AWS PrivateLink&lt;/li&gt;
&lt;li&gt;How We Structure the Terraform&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Part 1 built the environment. Now we hand out the keys — three account-level groups, two workspaces, and a permission model that's mostly &lt;em&gt;not&lt;/em&gt; something you invent.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here's the trap most Databricks RBAC posts fall into: they treat access control like a thing you design from scratch. You don't. Databricks already hands you USER and ADMIN at the workspace level, entitlements, object ACLs, and Unity Catalog grants — all built in. The only piece &lt;em&gt;you&lt;/em&gt; actually create is the groups. Get that mental split right and RBAC stops feeling like a maze.&lt;/p&gt;

&lt;p&gt;If you're standing up a fresh Databricks account and wondering where to draw the first lines, this is the layer to get right before you touch a single catalog.&lt;/p&gt;




&lt;h2&gt;
  
  
  The model in one line
&lt;/h2&gt;

&lt;p&gt;Everything flows through groups:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F19ibyyz1lfu3admgcpzm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F19ibyyz1lfu3admgcpzm.png" alt=" " width="800" height="553"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User ──▶ Function-Role group ──▶ workspace (USER / ADMIN) + (later) data grants
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A user gets nothing directly. They land in a &lt;strong&gt;function-role group&lt;/strong&gt;, and the group carries the permissions. That indirection is the whole point — it means "who is this person" and "what can this role do" are two separate problems you can solve independently.&lt;/p&gt;

&lt;p&gt;We started with the smallest set that still maps to real jobs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Intent&lt;/th&gt;
&lt;th&gt;Workspace level&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai_admin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Platform admins — run the place&lt;/td&gt;
&lt;td&gt;ADMIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai_engineer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ML / data engineers — build things&lt;/td&gt;
&lt;td&gt;USER&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai_analyst&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Analysts — read and query&lt;/td&gt;
&lt;td&gt;USER&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three groups. Not thirty. You can add &lt;code&gt;ai_scientist&lt;/code&gt;, &lt;code&gt;ai_guest&lt;/code&gt;, whatever later — each is one line of YAML plus an assignment. Resist the urge to pre-build a role for every hypothetical persona; churn kills that plan fast.&lt;/p&gt;

&lt;p&gt;These are &lt;strong&gt;account-level&lt;/strong&gt; groups, not workspace-local ones. That matters: one group definition can be assigned to many workspaces, which is exactly what you want when you have more than one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Groups are the only thing you create
&lt;/h2&gt;

&lt;p&gt;This is the part worth internalizing. Line up the permission layers and mark who owns each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Values&lt;/th&gt;
&lt;th&gt;Who defines it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Workspace assignment&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;USER&lt;/code&gt; / &lt;code&gt;ADMIN&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Databricks built-in&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entitlements&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;workspace_access&lt;/code&gt;, &lt;code&gt;databricks_sql_access&lt;/code&gt;, &lt;code&gt;allow_cluster_create&lt;/code&gt;, ...&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Databricks built-in&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Object ACLs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CAN_MANAGE&lt;/code&gt; / &lt;code&gt;CAN_USE&lt;/code&gt; / &lt;code&gt;CAN_ATTACH_TO&lt;/code&gt; / ...&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Databricks built-in&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unity Catalog grants&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;USE CATALOG&lt;/code&gt; / &lt;code&gt;SELECT&lt;/code&gt; / &lt;code&gt;MODIFY&lt;/code&gt; / &lt;code&gt;ALL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Databricks built-in&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Function-role groups&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ai_admin&lt;/code&gt;, &lt;code&gt;ai_engineer&lt;/code&gt;, ...&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;You&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four of the five rows are Databricks primitives. You don't design &lt;code&gt;SELECT&lt;/code&gt; or &lt;code&gt;CAN_MANAGE&lt;/code&gt; — they exist. What you design is the &lt;em&gt;subjects&lt;/em&gt;: the groups those permissions attach to. Everything else in this series (entitlements in Part 3, Unity Catalog grants later) is you handing built-in permissions to the groups you made here.&lt;/p&gt;

&lt;p&gt;So your IaC surface for RBAC is genuinely small. You define the groups in Terraform, and you wire them to workspaces. Membership — the actual humans — lives somewhere else. More on that in a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  USER vs ADMIN: built-in, and you just pick
&lt;/h2&gt;

&lt;p&gt;Once the groups exist, you assign each one to a workspace at a &lt;strong&gt;permission level&lt;/strong&gt;. Databricks gives you exactly two at this layer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ADMIN&lt;/strong&gt; — full workspace admin. Manage users, clusters, settings, everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;USER&lt;/strong&gt; — can log in and work, no admin surface.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it. You're not inventing a permission scheme; you're choosing which of two built-in levels each group gets, per workspace. In Terraform this is &lt;code&gt;databricks_mws_permission_assignment&lt;/code&gt; — an account-level resource (multi-workspace mode) that maps a group to a workspace at a level.&lt;/p&gt;

&lt;p&gt;With two workspaces — call them &lt;code&gt;landing&lt;/code&gt; and &lt;code&gt;pipeline&lt;/code&gt; — the matrix is small and readable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;landing&lt;/th&gt;
&lt;th&gt;pipeline&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai_admin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ADMIN&lt;/td&gt;
&lt;td&gt;ADMIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai_engineer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;USER&lt;/td&gt;
&lt;td&gt;USER&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai_analyst&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;USER&lt;/td&gt;
&lt;td&gt;USER&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Admins are admins everywhere; engineers and analysts are users everywhere. The point isn't the specific grid — it's that a whole workspace access policy is &lt;em&gt;this&lt;/em&gt; compact when the subjects are groups instead of individuals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where membership actually lives (hint: not IaC)
&lt;/h2&gt;

&lt;p&gt;Here's the decision that surprises people: &lt;strong&gt;user-to-group membership does not go in Terraform.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's tempting. You've got groups in code, you've got assignments in code — why not list the members in code too? Because joiners and leavers churn constantly, and every one of them would be a pull request, a plan, an apply. You'd be running infrastructure deploys to onboard an intern. That's the wrong tool.&lt;/p&gt;

&lt;p&gt;So membership lives in the &lt;strong&gt;Account Console / SCIM&lt;/strong&gt;, managed by ops (or synced from your IdP):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Account Console → User management → Users → &lt;strong&gt;Add user&lt;/strong&gt; (company email = login ID).&lt;/li&gt;
&lt;li&gt;Open the target group → &lt;strong&gt;Members&lt;/strong&gt; tab → Add members.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One gotcha worth calling out: add people on the &lt;strong&gt;Members&lt;/strong&gt; tab, not the Roles tab. The Roles tab is account-level roles (account admin, etc.) — a completely different thing, and easy to click by accident. And if you have SSO/SCIM provisioning, let the IdP own membership; manual adds will fight the sync.&lt;/p&gt;

&lt;p&gt;The clean split, then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In IaC (rarely changes):&lt;/strong&gt; group definitions, workspace assignments, and later the grants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Out of IaC (changes daily):&lt;/strong&gt; who's in each group.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Structure in code, people in the console. That line is the single most useful RBAC decision in this whole post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The apply-order gotcha that eats an afternoon
&lt;/h2&gt;

&lt;p&gt;Terraform (via Terragrunt) resolves this in two stacks: one that creates the &lt;strong&gt;groups&lt;/strong&gt;, one that creates the &lt;strong&gt;workspace assignments&lt;/strong&gt;. The assignment stack depends on the group stack's output — it needs the group IDs to bind them to workspaces.&lt;/p&gt;

&lt;p&gt;Here's the trap. If you apply the assignment stack &lt;strong&gt;before&lt;/strong&gt; the groups exist, it doesn't error. It quietly resolves the dependency to a mock (empty) output and produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;assignments&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty result. No groups, no bindings, no complaint. You think you shipped RBAC; you shipped nothing. Then you spend an hour wondering why nobody can log in.&lt;/p&gt;

&lt;p&gt;The order is non-negotiable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. groups FIRST&lt;/span&gt;
atlantis apply &lt;span class="nt"&gt;-d&lt;/span&gt; .../groups

&lt;span class="c"&gt;# 2. THEN the assignment (references group IDs)&lt;/span&gt;
atlantis apply &lt;span class="nt"&gt;-d&lt;/span&gt; .../workspace/workspace-assignment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you ever see &lt;code&gt;assignments = {}&lt;/code&gt; on a plan you expected to be full, this is why: the group output wasn't there yet, the dependency fell back to its mock, and the plan built against thin air. Apply groups, then re-plan. It's the RBAC cousin of "create the table before you grant on it."&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Groups are the only thing you invent.&lt;/strong&gt; USER/ADMIN, entitlements, ACLs, and Unity Catalog grants are all Databricks built-ins — you attach them, you don't design them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Function-role groups are account-level&lt;/strong&gt;, so one definition assigns cleanly to many workspaces. Start with three (admin/engineer/analyst); add more as one-liners when you actually need them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;USER vs ADMIN is a built-in binary&lt;/strong&gt; at the workspace layer — pick per group, per workspace, and let the group indirection keep the matrix tiny.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Membership belongs in the console/SCIM, not IaC.&lt;/strong&gt; Joiner/leaver churn would turn onboarding into infrastructure deploys. Structure in code, people out of code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create groups before workspace assignment.&lt;/strong&gt; Do it backwards and the dependency resolves to a mock, &lt;code&gt;assignments = {}&lt;/code&gt; ships silently, and nobody can log in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With the subjects in place and the workspaces handed out, the next question is what those users are actually allowed to &lt;em&gt;do&lt;/em&gt; with compute — who can spin up a cluster, which entitlements gate SQL, and how you keep costs from getting out of hand. That's Part 3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next:&lt;/strong&gt; &lt;em&gt;Compute governance — entitlements, cluster policies, and keeping a self-serve platform from becoming a self-serve bill.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>databricks</category>
      <category>aws</category>
      <category>rbac</category>
      <category>terraform</category>
    </item>
    <item>
      <title>[Databricks on AWS #1] Building a Databricks AI Platform on AWS: Two Workspaces, One Unity Catalog Metastore</title>
      <dc:creator>duke</dc:creator>
      <pubDate>Thu, 02 Jul 2026 01:24:21 +0000</pubDate>
      <link>https://dev.to/javaking1129/databricks-on-aws-1-building-a-databricks-ai-platform-on-aws-two-workspaces-one-unity-catalog-1i7i</link>
      <guid>https://dev.to/javaking1129/databricks-on-aws-1-building-a-databricks-ai-platform-on-aws-two-workspaces-one-unity-catalog-1i7i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;📚 Series: Databricks on AWS (Part 1)&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Building a Databricks AI Platform on AWS&lt;/strong&gt; ← you are here&lt;/li&gt;
&lt;li&gt;RBAC with Function-Role Groups&lt;/li&gt;
&lt;li&gt;Compute Governance: Pools, Policies, Clusters&lt;/li&gt;
&lt;li&gt;The BOOTSTRAP_TIMEOUT Mystery&lt;/li&gt;
&lt;li&gt;Fixing It with AWS PrivateLink&lt;/li&gt;
&lt;li&gt;How We Structure the Terraform&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We stood up two workspaces, one shared Unity Catalog metastore, a customer-managed VPC, and wired the whole thing through Terraform + Atlantis so nobody clicks in a console. This post is the map for everything that follows.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every "just spin up Databricks" tutorial stops at the point where the workspace turns green. Real platforms don't stop there. You have to decide how data flows, where it lives, who governs it, and how a change gets from a pull request to running infrastructure without someone SSH-ing into a jump box at 2am.&lt;/p&gt;

&lt;p&gt;This series is the honest version of that build — a Databricks AI platform on AWS, provisioned entirely as code, with all the sharp edges we caught our shins on. Part 1 is the architecture and the ground rules. Later parts go deep on RBAC, compute governance, and the networking rabbit hole that ate a week of my life (spoiler: a firewall silently dropping SYN packets).&lt;/p&gt;

&lt;p&gt;Let me lay out the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the platform
&lt;/h2&gt;

&lt;p&gt;Two workspaces. One metastore. One VPC. That's the headline.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq5wmsnnxximcf2tg80k7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq5wmsnnxximcf2tg80k7.png" alt=" " width="800" height="837"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    ┌─────────────────────────────────────┐
                    │      Unity Catalog metastore         │
                    │        (one per region)              │
                    └───────────────┬─────────────────────┘
                        assigned to both
             ┌──────────────────────┴──────────────────────┐
             │                                              │
   ┌─────────▼─────────┐                        ┌───────────▼───────────┐
   │  landing workspace │                        │  pipeline workspace   │
   │  ingest + interactive│                      │  transform + batch/job │
   │  analytics          │                       │  model train/serve    │
   └─────────┬──────────┘                        └───────────┬───────────┘
             │                                              │
             └───────────────┬──────────────────────────────┘
                             │
                   customer-managed VPC (shared)
                   shared root S3 + cross-account IAM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why two workspaces instead of one? Because the two halves of the platform have genuinely different personalities.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;landing workspace&lt;/strong&gt; is where raw data arrives and where analysts poke at it interactively. It's bursty, human-driven, notebook-heavy. The &lt;strong&gt;pipeline workspace&lt;/strong&gt; is where the scheduled jobs live — transformation, feature engineering, model training and serving. It's automated, batch-shaped, and you do not want an analyst's runaway all-purpose cluster competing with a production training job for pool capacity.&lt;/p&gt;

&lt;p&gt;Splitting by &lt;strong&gt;data flow&lt;/strong&gt; (ingest/interactive vs. transform/batch) instead of by team gives you a clean blast radius. A misbehaving interactive cluster can't starve the batch plane. Compute policies, instance pools, and cluster budgets get tuned per personality instead of averaged into mush.&lt;/p&gt;

&lt;p&gt;But — and this is the part people get wrong — splitting the workspaces does &lt;strong&gt;not&lt;/strong&gt; mean splitting the data governance.&lt;/p&gt;

&lt;h2&gt;
  
  
  One metastore to govern them all
&lt;/h2&gt;

&lt;p&gt;Unity Catalog's metastore is regional: &lt;strong&gt;one metastore per region, per account.&lt;/strong&gt; That's not a suggestion, it's a hard limit, and it turns out to be exactly what you want here.&lt;/p&gt;

&lt;p&gt;Both workspaces attach to the &lt;strong&gt;same&lt;/strong&gt; metastore. That means a table registered by an ingest job in the landing workspace is immediately governable, grantable, and queryable (subject to permissions) from the pipeline workspace — same three-level namespace (&lt;code&gt;catalog.schema.table&lt;/code&gt;), same lineage graph, same audit trail. No copying, no federation, no "which workspace has the real version" archaeology.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;metastore (region-wide)
 ├── catalog: landing_*     ← raw / bronze
 ├── catalog: pipeline_*    ← silver / gold
 └── grants + lineage span BOTH workspaces
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workspaces are the compute boundary. The metastore is the governance boundary. Keeping those two concepts separate is the single most important design decision in the whole platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Customer-managed VPC, shared storage
&lt;/h2&gt;

&lt;p&gt;The data plane runs in a &lt;strong&gt;customer-managed VPC&lt;/strong&gt; — our network, our subnets, our security groups — not the Databricks-managed default. On a real platform behind a security team, that's non-negotiable: you need the compute to live where your egress controls, flow logs, and inspection already are.&lt;/p&gt;

&lt;p&gt;In our case the VPC and subnets were &lt;strong&gt;created by the infra team and reused&lt;/strong&gt;, not provisioned by this repo. Terraform just &lt;em&gt;registers&lt;/em&gt; them with Databricks via &lt;code&gt;databricks_mws_networks&lt;/code&gt;. That distinction bites later (Part 4 is a whole post about a cluster that wouldn't boot because a firewall in that borrowed network was eating packets), so it's worth stating up front: &lt;strong&gt;owning the workspace config is not the same as owning the network.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Storage is shared too. Both workspaces sit on one root S3 bucket, reached through a single cross-account IAM role and one Databricks credential config. The metastore gets its own storage root plus a dedicated data-access role. Standard stuff — S3 gateway endpoint, STS and Kinesis interface endpoints, a security group allowing 443 — but "standard" only if someone actually built it, which is again the infra team, not this repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything through Terraform + Atlantis
&lt;/h2&gt;

&lt;p&gt;No console clicking. The entire platform — workspaces, networks, storage registration, the metastore, catalogs, schemas, groups, grants — is Terraform, wrapped in Terragrunt, applied through &lt;strong&gt;Atlantis&lt;/strong&gt; off GitLab merge requests.&lt;/p&gt;

&lt;p&gt;The flow is boring in the best way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;open MR  →  atlantis plan (comment on the MR)  →  review the plan
        →  merge  →  atlantis apply  →  infra changes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things make this actually work at platform scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A single automation service principal&lt;/strong&gt; owns every apply. Humans don't hold the keys; the SP does. (More on the permissions that SP needs below — it's a gotcha.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ordering is explicit.&lt;/strong&gt; Unity Catalog resources have real dependencies, so we apply in a fixed sequence: metastore → external locations → catalogs → schemas → groups → grants. Skip ahead and apply grants before the groups exist and you get a &lt;code&gt;Group not found&lt;/code&gt; error. Ask me how I know.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The payoff is that "what's deployed" always equals "what's in &lt;code&gt;main&lt;/code&gt;". Drift is a review comment, not a mystery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas we hit
&lt;/h2&gt;

&lt;p&gt;Here's the stuff no tutorial mentioned. Every one of these cost real time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The deployment-name prefix has to be registered by Databricks — you can't do it yourself.&lt;/strong&gt;&lt;br&gt;
Your workspace URL prefix (the &lt;code&gt;something.cloud.databricks.com&lt;/code&gt; part) must be pre-registered on your Databricks account &lt;em&gt;by Databricks&lt;/em&gt;. It's not a console setting and it's not an API call — you file a request with your Databricks contact. Try to create the workspace before it's registered and you get a flat &lt;code&gt;Deployment name cannot be used...&lt;/code&gt; error with no hint that the fix lives in someone else's ticket queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The automation service principal needs Account admin — not just workspace admin.&lt;/strong&gt;&lt;br&gt;
All the &lt;code&gt;databricks_mws_*&lt;/code&gt; resources (workspaces, networks, storage, Unity Catalog) are &lt;em&gt;account-level&lt;/em&gt;. A service principal with only workspace-level rights can't create any of them. The SP that runs your Atlantis applies has to be an &lt;strong&gt;Account admin&lt;/strong&gt;, or every &lt;code&gt;mws&lt;/code&gt; resource fails at plan-to-apply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The region auto-creates a default metastore that collides with yours.&lt;/strong&gt;&lt;br&gt;
Create your first workspace in a fresh region and Databricks helpfully auto-provisions a &lt;strong&gt;default metastore&lt;/strong&gt; for that region and attaches it. Since there's only one metastore per region, that default now &lt;em&gt;is&lt;/em&gt; the region's metastore — and it's not the managed one you want to define in code. The fix: detach the auto-created default, delete it, then create your own &lt;code&gt;*-metastore&lt;/code&gt; via Terraform and assign it. Bring your own metastore; evict the squatter first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The deploy box needs egress to the account console.&lt;/strong&gt;&lt;br&gt;
Atlantis runs somewhere, and that somewhere has to reach &lt;code&gt;accounts.cloud.databricks.com&lt;/code&gt; on 443 outbound. Behind a locked-down egress firewall, that's a rule someone has to add — and until they do, your applies just hang. Worth flagging clearly: this is the &lt;strong&gt;deploy server's&lt;/strong&gt; egress. The &lt;strong&gt;cluster nodes'&lt;/strong&gt; egress in the data plane is an entirely separate problem (and a much nastier one — that's Part 4).&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves us
&lt;/h2&gt;

&lt;p&gt;At the end of Part 1 we have the skeleton: two workspaces split by data flow, one shared regional Unity Catalog metastore governing both, a customer-managed VPC and shared S3 underneath, and Atlantis turning merge requests into infrastructure. Green workspaces, a clean namespace, and a deploy pipeline nobody has to babysit.&lt;/p&gt;

&lt;p&gt;What we &lt;em&gt;don't&lt;/em&gt; have yet is people. A platform with no access model is just an expensive sandbox. Next up: how we built a three-tier RBAC system — users mapped to function roles, function roles to access roles, access roles to actual Unity Catalog grants — so that "give the ML team read on the gold catalog" is a one-line change instead of a permissions spelunking expedition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next:&lt;/strong&gt; &lt;em&gt;Part 2 — RBAC done right: function-role groups, access-role groups, and why we have two layers instead of one.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>databricks</category>
      <category>aws</category>
      <category>terraform</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>[Databricks on AWS #0] The Target Architecture: Isolating Prod, Dev, and Sandbox with Unity Catalog</title>
      <dc:creator>duke</dc:creator>
      <pubDate>Thu, 02 Jul 2026 00:34:09 +0000</pubDate>
      <link>https://dev.to/javaking1129/databricks-on-aws-0-the-target-architecture-isolating-prod-dev-and-sandbox-with-unity-catalog-1j79</link>
      <guid>https://dev.to/javaking1129/databricks-on-aws-0-the-target-architecture-isolating-prod-dev-and-sandbox-with-unity-catalog-1j79</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;📚 Series: Databricks on AWS (Part 0, prologue)&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Target Architecture&lt;/strong&gt; ← you are here&lt;/li&gt;
&lt;li&gt;Building a Databricks AI Platform on AWS&lt;/li&gt;
&lt;li&gt;RBAC with Function-Role Groups&lt;/li&gt;
&lt;li&gt;Compute Governance: Pools, Policies, Clusters&lt;/li&gt;
&lt;li&gt;The BOOTSTRAP_TIMEOUT Mystery&lt;/li&gt;
&lt;li&gt;Fixing It with AWS PrivateLink&lt;/li&gt;
&lt;li&gt;How We Structure the Terraform&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Before the build story, here's the destination. This is the target-state data architecture we designed the whole platform toward — the three principles that shaped every later decision, and the Unity Catalog governance model that keeps production data safe from human hands.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rest of this series is a build log: workspaces, RBAC, compute, the networking rabbit hole, the Terraform layout. But every one of those decisions was made in service of a target picture we drew &lt;em&gt;first&lt;/em&gt;. This post is that picture — the "to-be" architecture, not the scaffolding we happened to have up on any given week.&lt;/p&gt;

&lt;p&gt;It's built on three things Databricks basically hands you if you lean into them: the &lt;strong&gt;Lakehouse&lt;/strong&gt; (one store, ACID tables, no separate warehouse to sync), the &lt;strong&gt;Medallion architecture&lt;/strong&gt; (raw → cleaned → integrated → business, each layer a promotion), and &lt;strong&gt;Unity Catalog&lt;/strong&gt; as the single governance plane across all of it. The interesting part isn't reciting those three buzzwords — it's the specific way we wire them so that prod, dev, and analyst sandboxes never step on each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three principles, and everything follows
&lt;/h2&gt;

&lt;p&gt;Almost every concrete rule later in this series is a consequence of one of these three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Nobody touches production by hand.&lt;/strong&gt; Create, update, delete in prod data happens &lt;em&gt;only&lt;/em&gt; through an automated, code-reviewed pipeline running as a service principal. Human accounts don't get write on prod — not analysts, not engineers, not admins. The blast radius of a bad afternoon is capped at whatever a person can do with read-only. This one principle is why the whole "promote" flow later exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Never copy production to look at it.&lt;/strong&gt; If an analyst wants to explore the gold layer, they read it &lt;em&gt;in place&lt;/em&gt;. Within one metastore that's just Unity Catalog namespace permissions; across metastores or orgs it's Delta Sharing. Either way the bytes don't move. No nightly "analytics copy" job, no storage bill for the same data three times, no stale replica that quietly drifts from the source of truth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Give analysts a room they can trash.&lt;/strong&gt; Read-only-on-prod sounds clean until an analyst runs the same 200-line &lt;code&gt;WITH&lt;/code&gt; query forty times in an afternoon, full-scanning a fact table each run. So we give them a &lt;strong&gt;sandbox&lt;/strong&gt; — a physically isolated catalog where they &lt;em&gt;can&lt;/em&gt; write — and encourage them to materialize heavy intermediate results there once. Freedom to write, but walled off from anything that matters.&lt;/p&gt;

&lt;p&gt;Hold those three in your head. The zone model below is just their logical consequence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The zone model
&lt;/h2&gt;

&lt;p&gt;Inside one Unity Catalog metastore we carve out three zones with sharply different permission profiles.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F521ubp7bh7o8tl2whb5k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F521ubp7bh7o8tl2whb5k.png" alt=" " width="800" height="852"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;              ┌──────────────── Unity Catalog metastore ────────────────┐
              │                                                          │
 pipeline-sp ─┼─▶ ┌──── PROD (Medallion) ────┐      ┌─── SANDBOX ───┐    │
 (write only) │   │ landing→cleaned→         │ zero │  per-user     │    │
              │   │ integrated→business      │─copy▶│  schemas      │    │
              │   │ read-only for humans     │ read │  free write   │    │
              │   └──────────────────────────┘      └───────────────┘    │
              │                 ▲                          │             │
 humans ──────┼─── read (SELECT)│              free write (ALL)│         │
 ai_analyst   │                 │                                        │
 ai_engineer  │   ┌──── DEV (Medallion) ─────┐                           │
              │   │ same layers, engineers   │  build &amp;amp; validate         │
              │   │ can write                 │  pipelines here          │
              │   └──────────────────────────┘                           │
              └──────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Zone&lt;/th&gt;
&lt;th&gt;Catalogs&lt;/th&gt;
&lt;th&gt;Who&lt;/th&gt;
&lt;th&gt;Privileges&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Prod&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;landing&lt;/code&gt;, &lt;code&gt;cleaned&lt;/code&gt;, &lt;code&gt;integrated&lt;/code&gt;, &lt;code&gt;business&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;pipeline service principal&lt;/strong&gt; (write), human groups (read)&lt;/td&gt;
&lt;td&gt;SP: &lt;code&gt;USE&lt;/code&gt; &lt;code&gt;SELECT&lt;/code&gt; &lt;code&gt;CREATE&lt;/code&gt; &lt;code&gt;MODIFY&lt;/code&gt; (all); humans: &lt;code&gt;USE&lt;/code&gt; &lt;code&gt;SELECT&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;Single source of truth. Writes only via automation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dev&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;same four Medallion catalogs, dev env&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ai_engineer&lt;/code&gt;, &lt;code&gt;ai_admin&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;USE&lt;/code&gt; &lt;code&gt;SELECT&lt;/code&gt; &lt;code&gt;CREATE&lt;/code&gt; &lt;code&gt;MODIFY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Where pipeline logic is built and validated before it's promoted.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sandbox&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sandbox&lt;/code&gt; (per-user schemas)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ai_analyst&lt;/code&gt;, &lt;code&gt;ai_engineer&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ALL&lt;/code&gt; on your own schema; read-only on prod/dev&lt;/td&gt;
&lt;td&gt;Free-write experiment space + cost-saving materialization.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The four Medallion catalogs — &lt;code&gt;landing&lt;/code&gt; (bronze/raw), &lt;code&gt;cleaned&lt;/code&gt; (silver, incremental + cleansing), &lt;code&gt;integrated&lt;/code&gt; (silver, dimensions + facts), &lt;code&gt;business&lt;/code&gt; (gold, marts) — exist in both prod and dev with an environment prefix. Same shape, different blast radius: in dev engineers write freely; in prod only the pipeline principal does.&lt;/p&gt;

&lt;p&gt;The three human groups (&lt;code&gt;ai_admin&lt;/code&gt;, &lt;code&gt;ai_engineer&lt;/code&gt;, &lt;code&gt;ai_analyst&lt;/code&gt;) come straight from the RBAC post — account-level function-role groups. Nothing here invents a new permission system; it just points Unity Catalog's built-in privileges at those groups, per zone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: zero-copy reads
&lt;/h2&gt;

&lt;p&gt;Production data exists exactly once, and everyone reads that one copy without cloning it.&lt;/p&gt;

&lt;p&gt;Inside a single metastore, an analyst in the sandbox querying &lt;code&gt;prod_business.gold.sales_fact&lt;/code&gt; is just a permission grant — &lt;code&gt;SELECT&lt;/code&gt;, no replication, no extra storage. Cross a metastore or org boundary (say a separate prod org sharing down to an analytics org) and it becomes Delta Sharing: still read-only, still metadata-only, the bytes stay put.&lt;/p&gt;

&lt;p&gt;And because human accounts only ever get &lt;code&gt;SELECT&lt;/code&gt; on prod, principle #1 holds automatically — there's no code path where a person's typo mutates the gold layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2: materialize once, in the sandbox
&lt;/h2&gt;

&lt;p&gt;Read-only prod plus a curious analyst equals a specific failure mode: the same expensive query, run over and over, full-scanning a fact table every single time. The fix isn't to lock the analyst down — it's to give them somewhere to &lt;em&gt;land&lt;/em&gt; the expensive part once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Materialize the heavy part once, in your own sandbox schema&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dev_sandbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sandbox_user_a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;monthly_summary&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;prod_business&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gold&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sales_fact&lt;/span&gt;   &lt;span class="c1"&gt;-- zero-copy read (UC grant / Delta Sharing)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;base_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-01-01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every follow-up query hits &lt;code&gt;monthly_summary&lt;/code&gt; — a small, physical Delta table the analyst owns — instead of re-scanning the production fact table. The repeated full scans collapse to a single one. Per-user schemas keep everyone's scratch tables from colliding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3: the promote path
&lt;/h2&gt;

&lt;p&gt;So an analyst builds something great in their sandbox. How does it become a real production asset? Not by anyone copying it into prod — principle #1 forbids that. It goes through a promote flow that &lt;em&gt;structurally&lt;/em&gt; keeps humans out of the production write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Sandbox]  analyst experiments &amp;amp; validates  (sandbox.&amp;lt;user&amp;gt;)
    │
    ▼  hand off — analysis code goes to the data-engineering team
    │
    ▼  code review — query optimization (Z-Order, partition pruning), naming/schema standards
    │
    ▼  pipeline intake — validated in dev, then registered in the prod pipeline
    │
    ▼  productionize — the pipeline service principal writes it into the prod Medallion layers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last writer is always the pipeline principal. The sandbox is where ideas are born; the promote path is the airlock they pass through to become production data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The infra standard underneath
&lt;/h2&gt;

&lt;p&gt;Two platform-wide defaults make the cost story actually hold up (more in the compute post):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Serverless SQL Warehouse as the default query engine.&lt;/strong&gt; Analyst load is spiky and unpredictable, so we don't leave fixed interactive clusters running. Serverless bills per-second while a query runs and auto-terminates within a minute or two of idle — the "someone forgot to shut it down" bill is designed out at the architecture level, not left to discipline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost tracking from Unity Catalog system tables.&lt;/strong&gt; &lt;code&gt;system.billing.usage&lt;/code&gt; and &lt;code&gt;system.query.history&lt;/code&gt; feed a live dashboard, so we can see exactly who's running the expensive full scans in the sandbox and which queries burn the most. That same data backs the query-optimization review in the promote path — the decision to materialize or Z-order isn't a guess, it's a number.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Three zones, three permission profiles.&lt;/strong&gt; Prod is read-only for humans and written only by automation; dev is where engineers build; sandbox is a walled room analysts can write in freely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never copy prod.&lt;/strong&gt; Zero-copy reads (UC grants or Delta Sharing) mean one source of truth, one storage bill, zero drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Materialize in the sandbox, promote through review.&lt;/strong&gt; Heavy intermediates land once in a per-user schema; anything worth keeping goes back to prod only through a code-reviewed pipeline running as a service principal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serverless + system tables&lt;/strong&gt; make the cost model self-enforcing instead of aspirational.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the destination. The next five posts are how we actually built toward it — starting with the workspaces and the metastore.&lt;/p&gt;

</description>
      <category>databricks</category>
      <category>lakehouse</category>
      <category>dataengineering</category>
      <category>governance</category>
    </item>
    <item>
      <title>[Open-Source LLM Agent #3] Running a Whole RAG Agent Offline: LangGraph + Ollama + Embedded Qdrant (Zero API Keys)</title>
      <dc:creator>duke</dc:creator>
      <pubDate>Mon, 29 Jun 2026 01:22:31 +0000</pubDate>
      <link>https://dev.to/javaking1129/running-a-whole-rag-agent-offline-langgraph-ollama-embedded-qdrant-zero-api-keys-2hfd</link>
      <guid>https://dev.to/javaking1129/running-a-whole-rag-agent-offline-langgraph-ollama-embedded-qdrant-zero-api-keys-2hfd</guid>
      <description>&lt;p&gt;Most RAG tutorials open with "set your &lt;code&gt;OPENAI_API_KEY&lt;/code&gt;." This one doesn't need it. In &lt;a href="https://dev.to/javaking1129/running-a-langgraph-react-agent-in-production-openai-compatible-api-multi-model-gateway--emi"&gt;Part 1&lt;/a&gt; I claimed the LLM and embeddings are behind a swappable boundary — "switch providers via config, not code." Part 3 is me &lt;em&gt;cashing that claim&lt;/em&gt;: running the entire RAG agent — ingestion, retrieval, the ReAct loop, source citations — on a laptop with &lt;strong&gt;zero API keys and no Docker&lt;/strong&gt;, just Ollama and an embedded Qdrant.&lt;/p&gt;

&lt;p&gt;Everything below is real output from an actual run. Including the one thing that broke.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "offline" actually requires
&lt;/h2&gt;

&lt;p&gt;Three pieces, all local:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ollama&lt;/strong&gt; running two models — one for chat, one for embeddings:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  ollama pull qwen3.5:9b   &lt;span class="c"&gt;# chat / reasoning&lt;/span&gt;
  ollama pull bge-m3       &lt;span class="c"&gt;# embeddings (1024-dim, multilingual)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Embedded Qdrant&lt;/strong&gt; — no server, no container. The vector store writes to a local directory.&lt;/li&gt;
&lt;li&gt;A one-line config flip so chat goes to Ollama instead of the gateway:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  &lt;span class="nv"&gt;CHAT_PROVIDER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ollama
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No &lt;code&gt;OPENAI_API_KEY&lt;/code&gt;, no &lt;code&gt;docker compose up&lt;/code&gt;. The reason this is a &lt;em&gt;flip&lt;/em&gt; and not a rewrite is the provider-swap design from Part 1 — let's look at the three factories that make it work.&lt;/p&gt;




&lt;h2&gt;
  
  
  The embeddings factory — swap by config
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/llm/embeddings.py
&lt;/span&gt;&lt;span class="nd"&gt;@lru_cache&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_embeddings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Embeddings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_settings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ollama&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_ollama&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OllamaEmbeddings&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;OllamaEmbeddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ollama_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAIEmbeddings&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;OpenAIEmbeddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;litellm_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;litellm_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding_model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown embedding_provider: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding_provider&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both branches return the same LangChain &lt;code&gt;Embeddings&lt;/code&gt; interface, so the ingestion and retrieval code never knows which one it got. Local dev → Ollama (offline). Production → OpenAI via the gateway. &lt;strong&gt;One caveat that matters later:&lt;/strong&gt; the two providers produce &lt;em&gt;different vector dimensions&lt;/em&gt;, so you can't mix vectors ingested with one and queried with the other. More on that in the gotchas.&lt;/p&gt;

&lt;h2&gt;
  
  
  The vector store — embedded vs. remote, also by config
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/rag/store.py
&lt;/span&gt;&lt;span class="nd"&gt;@lru_cache&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;QdrantClient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_settings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;qdrant_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;QdrantClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;qdrant_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;qdrant_api_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# remote (prod)
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;QdrantClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;qdrant_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                             &lt;span class="c1"&gt;# embedded (local)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;QDRANT_URL&lt;/code&gt;? You get an embedded client that persists to &lt;code&gt;s.qdrant_path&lt;/code&gt; — a plain directory. Set &lt;code&gt;QDRANT_URL&lt;/code&gt; in prod and the &lt;em&gt;same code&lt;/em&gt; talks to a real Qdrant service. The trade-off of embedded mode: it &lt;strong&gt;locks the directory to a single process&lt;/strong&gt;, which becomes gotcha #2.&lt;/p&gt;




&lt;h2&gt;
  
  
  Ingestion: docs → chunks → vectors
&lt;/h2&gt;

&lt;p&gt;The ingest script is the whole pipeline in ~30 lines: load files, split them, probe the embedding dimension, create the collection, upsert.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# scripts/ingest.py (trimmed)
&lt;/span&gt;&lt;span class="n"&gt;splitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RecursiveCharacterTextSplitter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_overlap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;splitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# probe the embedding dimension so the collection matches the provider
&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;get_embeddings&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;embed_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;probe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;ensure_collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;get_vector_store&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;add_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;embed_query("probe")&lt;/code&gt; trick is worth pausing on: instead of hard-coding &lt;code&gt;1024&lt;/code&gt; for bge-m3 (or &lt;code&gt;1536&lt;/code&gt; for OpenAI), it asks the active embedder for one vector and measures it. Swap the provider and the collection is created with the right size automatically.&lt;/p&gt;

&lt;p&gt;Running it for real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python scripts/ingest.py &lt;span class="nt"&gt;--reset&lt;/span&gt;
&lt;span class="go"&gt;[ingest] source=docs  collection=docs  embed=ollama:bge-m3
[ingest] 5 documents → 53 chunks
[ingest] embedding dim = 1024
[ingest] done — 53 points in collection
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five markdown files, 53 chunks, 1024-dim vectors from bge-m3, written to the local Qdrant directory. No network calls left the machine.&lt;/p&gt;




&lt;h2&gt;
  
  
  Running the agent — no server needed
&lt;/h2&gt;

&lt;p&gt;You can hit the FastAPI endpoint, but to &lt;em&gt;see the graph think&lt;/em&gt; you can also invoke it directly. Here's a real run, asking about something that lives in the docs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ainvoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;HumanMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;How is short-term vs long-term memory implemented in this project?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]})&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
&lt;span class="c1"&gt;# ['HumanMessage', 'AIMessage', 'ToolMessage', 'AIMessage']
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That message sequence &lt;em&gt;is&lt;/em&gt; the ReAct loop, visible in the state:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;HumanMessage&lt;/code&gt; — the question&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AIMessage&lt;/code&gt; with &lt;code&gt;tool_calls=[search_docs(...)]&lt;/code&gt; — the model decides to retrieve&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ToolMessage&lt;/code&gt; — the retrieved chunks come back&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AIMessage&lt;/code&gt; — the final synthesized answer&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the answer itself, generated entirely by a 9B model on the laptop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Short-term memory: PostgreSQL (PostgresSaver) stores per-thread
  conversation state; swappable to Redis (RedisSaver) if needed.
Long-term memory: Zep manages the user's persistent knowledge,
  recalled by the app on later turns.

Sources: &amp;lt;doc-a&amp;gt;.md, &amp;lt;doc-b&amp;gt;.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grounded in the actual docs, with source attribution, zero API keys. That's the win. Now the part the tutorials skip.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotchas (the part that's actually worth reading)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The empty synthesis turn — the local model, not the pipeline
&lt;/h3&gt;

&lt;p&gt;On one run, the &lt;em&gt;exact same question&lt;/em&gt; produced this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1] AIMessage   content=''   tool_calls=[search_docs(...)]   finish_reason='tool_calls'
[2] ToolMessage content='[1] (source: ...) ## memory layers ...'   ← retrieval worked
[3] AIMessage   content=''   tool_calls=[]   finish_reason='stop'  ← empty answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retrieval succeeded. The chunks were right there in step 2. But step 3 — the model's job to &lt;em&gt;read the chunks and answer&lt;/em&gt; — came back &lt;strong&gt;empty&lt;/strong&gt;. &lt;code&gt;finish_reason='stop'&lt;/code&gt;, no tokens, no error. Re-running the same question gave a perfectly good 280-character answer with citations. So it's &lt;strong&gt;intermittent&lt;/strong&gt;: a small local model occasionally produces an empty turn after a tool call.&lt;/p&gt;

&lt;p&gt;Two things to take away:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's the &lt;em&gt;model&lt;/em&gt;, not your graph. The pipeline (routing → retrieval → state) was flawless; the synthesis step just whiffed.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;saw_token&lt;/code&gt; fallback from &lt;a href="https://dev.to/javaking1129/streaming-a-langgraph-agent-as-openai-compatible-sse-with-a-thinking-panel-2928"&gt;Part 2&lt;/a&gt; &lt;strong&gt;won't save you here&lt;/strong&gt; — that fallback calls &lt;code&gt;ainvoke&lt;/code&gt; when no tokens stream, but here &lt;code&gt;ainvoke&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the empty result. The real mitigations are a larger/better tool-tuned local model, or accepting some flakiness as the price of fully offline. Worth knowing before you demo it live.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Embedded Qdrant locks the directory
&lt;/h3&gt;

&lt;p&gt;Embedded mode keeps the store in one process. Run the ingest script while the server is up and you'll get a lock error. Order matters: &lt;strong&gt;ingest first → let it exit → then start the server.&lt;/strong&gt; The ingest script even closes the client explicitly to avoid a noisy shutdown traceback.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Embedding dimensions must match end to end
&lt;/h3&gt;

&lt;p&gt;bge-m3 is 1024-dim; OpenAI's &lt;code&gt;text-embedding-3-small&lt;/code&gt; is 1536. If you ingest with one provider and query with another, the dimensions don't line up and search breaks. Switching &lt;code&gt;embedding_provider&lt;/code&gt; means &lt;strong&gt;re-ingesting&lt;/strong&gt; (&lt;code&gt;--reset&lt;/code&gt;). The &lt;code&gt;embed_query("probe")&lt;/code&gt; dimension check is exactly what keeps the collection honest per provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The first call is slow
&lt;/h3&gt;

&lt;p&gt;Ollama loads the model into memory on first use. The first request eats that cost; subsequent ones are fast. Don't benchmark the cold start.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;You can build, debug, and demo the &lt;em&gt;entire&lt;/em&gt; RAG agent — graph, retrieval, citations — on a plane with no wifi. Then, for production, you flip two config values (&lt;code&gt;CHAT_PROVIDER&lt;/code&gt;, &lt;code&gt;QDRANT_URL&lt;/code&gt;) and the same code talks to a hosted model and a real Qdrant cluster. Part 1 &lt;em&gt;claimed&lt;/em&gt; the provider boundary; Part 3 &lt;em&gt;ran on both sides of it&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The flip side is honesty about local models: retrieval is rock-solid, but a 9B model's synthesis step is the weak link, and it'll occasionally hand you an empty answer. Know that going in.&lt;/p&gt;

&lt;p&gt;Next: persisting conversation threads with a checkpointer — so the agent remembers across requests — and what that adds to the message log you just saw.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 3 of a series on running LangGraph in production. &lt;a href="https://dev.to/javaking1129/running-a-langgraph-react-agent-in-production-openai-compatible-api-multi-model-gateway--emi"&gt;Part 1&lt;/a&gt; · &lt;a href="https://dev.to/javaking1129/streaming-a-langgraph-agent-as-openai-compatible-sse-with-a-thinking-panel-2928"&gt;Part 2&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>langchain</category>
      <category>llm</category>
      <category>rag</category>
      <category>ollama</category>
    </item>
    <item>
      <title>[Open-Source LLM Agent #2] Streaming a LangGraph Agent as OpenAI-Compatible SSE (with a Thinking Panel)</title>
      <dc:creator>duke</dc:creator>
      <pubDate>Wed, 24 Jun 2026 01:00:27 +0000</pubDate>
      <link>https://dev.to/javaking1129/streaming-a-langgraph-agent-as-openai-compatible-sse-with-a-thinking-panel-2928</link>
      <guid>https://dev.to/javaking1129/streaming-a-langgraph-agent-as-openai-compatible-sse-with-a-thinking-panel-2928</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/javaking1129/running-a-langgraph-react-agent-in-production-openai-compatible-api-multi-model-gateway--emi"&gt;Part 1&lt;/a&gt; I built a LangGraph ReAct agent behind an OpenAI-compatible API and waved at one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;StreamingResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;graph_to_openai_sse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                         &lt;span class="n"&gt;media_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text/event-stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;graph_to_openai_sse&lt;/code&gt; is where the real work hides. An OpenAI client like Open WebUI doesn't want "a LangGraph run" — it wants a very specific stream of &lt;code&gt;chat.completion.chunk&lt;/code&gt; JSON objects over Server-Sent Events, terminated by a &lt;code&gt;[DONE]&lt;/code&gt; sentinel. LangGraph, meanwhile, emits its &lt;em&gt;own&lt;/em&gt; rich event stream. This post is the adapter between the two — about 90 lines that also give you a free "thinking" panel showing the agent's tool calls as they happen.&lt;/p&gt;




&lt;h2&gt;
  
  
  The two formats
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What the client expects&lt;/strong&gt; — each token arrives as an SSE line: &lt;code&gt;data: {json}\n\n&lt;/code&gt;, where the JSON is an OpenAI chunk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/api/openai_compat.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;make_chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completion_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;finish_reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;completion_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                       &lt;span class="c1"&gt;# "chatcmpl-..."
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat.completion.chunk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;finish_reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;finish_reason&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stream has a strict shape:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;a first chunk with &lt;code&gt;delta = {"role": "assistant"}&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;many chunks with &lt;code&gt;delta = {"content": "..."}&lt;/code&gt; — one per token,&lt;/li&gt;
&lt;li&gt;a final chunk with empty delta and &lt;code&gt;finish_reason = "stop"&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;the literal line &lt;code&gt;data: [DONE]\n\n&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Miss the &lt;code&gt;[DONE]&lt;/code&gt; and the client spins forever. Skip the role chunk and some clients drop the first token. The contract is small but unforgiving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What LangGraph emits&lt;/strong&gt; — &lt;code&gt;astream_events&lt;/code&gt; is a single async stream of &lt;em&gt;typed&lt;/em&gt; events for everything happening inside the graph: model tokens, tool calls, node transitions. We subscribe once and translate each event we care about into chunks.&lt;/p&gt;




&lt;h2&gt;
  
  
  The core loop
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/api/streaming.py
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;graph_to_openai_sse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;completion_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;new_completion_id&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;_sse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;make_chunk&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completion_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# (1) role
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_sse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;make_chunk&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completion_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astream_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on_chat_model_stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chunk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AIMessageChunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                     &lt;span class="c1"&gt;# (2) tokens
&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;_sse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;make_chunk&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completion_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;finish_reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# (3) stop
&lt;/span&gt;    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data: [DONE]&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;                                                     &lt;span class="c1"&gt;# (4) done
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;version="v2"&lt;/code&gt;&lt;/strong&gt; pins the event schema. The event stream format has changed across LangChain releases; pinning it means your &lt;code&gt;metadata.langgraph_node&lt;/code&gt; and &lt;code&gt;data.chunk&lt;/code&gt; keys don't silently move under you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;on_chat_model_stream&lt;/code&gt;&lt;/strong&gt; is the token event. Its &lt;code&gt;data.chunk&lt;/code&gt; is an &lt;code&gt;AIMessageChunk&lt;/code&gt; — but only when the LLM is actually streaming. Guarding with &lt;code&gt;isinstance(...)&lt;/code&gt; avoids crashing on the non-streaming events that also flow through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One &lt;code&gt;completion_id&lt;/code&gt; for the whole response.&lt;/strong&gt; Every chunk in a single completion shares it; that's how the client stitches tokens into one message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;_sse&lt;/code&gt; is just the wire framing — and note &lt;code&gt;ensure_ascii=False&lt;/code&gt;, which matters the moment your tokens are Korean, Japanese, or emoji:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_sse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Surfacing the agent's thinking
&lt;/h2&gt;

&lt;p&gt;Streaming the final answer is table stakes. The interesting part of a ReAct agent is &lt;em&gt;what it did before answering&lt;/em&gt; — which document it searched, what came back. Open WebUI renders any text wrapped in &lt;code&gt;&amp;lt;think&amp;gt;...&amp;lt;/think&amp;gt;&lt;/code&gt; as a collapsible reasoning panel. So we narrate tool activity into that panel.&lt;/p&gt;

&lt;p&gt;First, label the nodes worth announcing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;NODE_LABELS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🔍 Searching the docs…&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open a &lt;code&gt;&amp;lt;think&amp;gt;&lt;/code&gt; block, and on the relevant events, emit human-readable progress instead of raw tokens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="n"&gt;show_thinking&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NODE_LABELS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;think_open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;prev_node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;show_thinking&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;think&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;think_open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astream_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metadata&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;langgraph_node&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# node entry → status line
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;prev_node&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;NODE_LABELS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;NODE_LABELS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;prev_node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on_tool_start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  • `&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;` running…&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on_tool_end&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;hasattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;snippet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())[:&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# collapse whitespace, clip
&lt;/span&gt;            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; ✓ `&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;snippet&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;…`&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;snippet&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; ✓&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="c1"&gt;# ... on_chat_model_stream handled as before
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;on_tool_end&lt;/code&gt; output is a &lt;code&gt;ToolMessage&lt;/code&gt;, so its text lives on &lt;code&gt;.content&lt;/code&gt; — hence the &lt;code&gt;hasattr(output, "content")&lt;/code&gt; check before falling back to &lt;code&gt;str()&lt;/code&gt;. Collapsing whitespace and clipping to ~90 chars keeps the panel readable instead of dumping a wall of retrieved text.&lt;/p&gt;

&lt;p&gt;Closing the panel has to happen no matter how the stream ends — success, exception, or early return — so it goes in a &lt;code&gt;finally&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;think_open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;_sse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;make_chunk&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/think&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completion_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result in the UI: a collapsible &lt;strong&gt;"🔍 Searching the docs… ✓"&lt;/strong&gt; panel, then the streamed answer below it. The user sees the agent reach for RAG in real time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two production details that bite
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Errors belong in the stream, not in a 500.&lt;/strong&gt; Once you've started streaming, the HTTP status is already &lt;code&gt;200&lt;/code&gt; and headers are flushed — you can't switch to an error response. So catch inside the generator and emit the error as content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;_sse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;make_chunk&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;[error] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completion_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user sees &lt;code&gt;[error] ...&lt;/code&gt; in the chat instead of a frozen, half-rendered message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Not every model streams.&lt;/strong&gt; Some gateways/models return a single batched response with no &lt;code&gt;on_chat_model_stream&lt;/code&gt; events at all. If you only ever forwarded tokens, those models would yield an &lt;em&gt;empty&lt;/em&gt; answer. Track whether any token was seen, and if not, fall back to a plain &lt;code&gt;ainvoke&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;saw_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ainvoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;final&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_final_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]))&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;final&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;extract_final_text&lt;/code&gt; walks the message log backwards for the last non-empty &lt;code&gt;AIMessage&lt;/code&gt; — handling both plain-string content and the list-of-blocks shape some providers return. This one guard is the difference between "streaming works on my dev model" and "works on every model behind the gateway."&lt;/p&gt;




&lt;h2&gt;
  
  
  The shape of the whole thing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph.astream_events(version="v2")
        │
        ├─ on_chat_model_stream → emit({"content": token})
        ├─ node entry           → emit("🔍 status line")   ┐
        ├─ on_tool_start        → emit("• tool running…")  ├─ inside &amp;lt;think&amp;gt;…&amp;lt;/think&amp;gt;
        ├─ on_tool_end          → emit("✓ snippet…")       ┘
        └─ (exception)          → emit("[error] …")
        ▼
 first chunk {role}  →  …content chunks…  →  {finish_reason: stop}  →  data: [DONE]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payoff from Part 1 compounds here: because the boundary is &lt;em&gt;just&lt;/em&gt; OpenAI SSE, this thinking-panel UX shows up in &lt;strong&gt;any&lt;/strong&gt; OpenAI-compatible client with zero client code. You wrote a translator, and every frontend in that ecosystem speaks it for free.&lt;/p&gt;

&lt;p&gt;Next up: persisting conversation threads with a checkpointer so the agent remembers across requests — and what that does to the streaming loop.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with LangGraph, LangChain, and FastAPI. Part 2 of a series on running LangGraph in production — &lt;a href="https://dev.to/javaking1129/running-a-langgraph-react-agent-in-production-openai-compatible-api-multi-model-gateway--emi"&gt;Part 1 here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>langchain</category>
      <category>llm</category>
      <category>python</category>
      <category>streaming</category>
    </item>
    <item>
      <title>[Open-Source LLM Agent #1] Running a LangGraph ReAct Agent in Production: OpenAI-Compatible API + Multi-Model Gateway + One-Line Tracing</title>
      <dc:creator>duke</dc:creator>
      <pubDate>Tue, 23 Jun 2026 22:57:54 +0000</pubDate>
      <link>https://dev.to/javaking1129/running-a-langgraph-react-agent-in-production-openai-compatible-api-multi-model-gateway--emi</link>
      <guid>https://dev.to/javaking1129/running-a-langgraph-react-agent-in-production-openai-compatible-api-multi-model-gateway--emi</guid>
      <description>&lt;p&gt;Most LangGraph content stops at the notebook. You build a cute ReAct loop, it answers one question, and the article ends before the hard part: &lt;em&gt;how do you actually serve this thing, swap models without a rewrite, and see what it's doing when it misbehaves?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This post walks through a small but &lt;strong&gt;production-shaped&lt;/strong&gt; LangGraph deployment: a RAG ReAct agent that&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;exposes an &lt;strong&gt;OpenAI-compatible HTTP API&lt;/strong&gt;, so any OpenAI client (Open WebUI, the &lt;code&gt;openai&lt;/code&gt; SDK, LibreChat) can talk to it unchanged,&lt;/li&gt;
&lt;li&gt;routes every model call through a &lt;strong&gt;gateway&lt;/strong&gt; so switching from a hosted API to self-hosted vLLM is a config change, not a code change, and&lt;/li&gt;
&lt;li&gt;gets &lt;strong&gt;full tracing&lt;/strong&gt; — node transitions, tool calls, and LLM calls in one trace — by adding a single callback.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every snippet below is real code from a working service. Roughly 150 lines of Python is all it takes.&lt;/p&gt;




&lt;h2&gt;
  
  
  The shape of the thing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OpenAI client (Open WebUI, openai SDK)
        │  POST /v1/chat/completions
        ▼
FastAPI router ──► LangGraph StateGraph ──► LLM Gateway ──► model (hosted API today, vLLM tomorrow)
        │                   │
        │                   └──► ToolNode ──► Qdrant (RAG)
        │
        └──► Langfuse callback (one trace per request)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The contract with the outside world is &lt;strong&gt;just the OpenAI API&lt;/strong&gt;. Everything interesting — the graph, RAG, tracing — lives behind that boundary. That single decision is what lets an off-the-shelf chat UI drive a custom agent with zero adapter code.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The ReAct graph
&lt;/h2&gt;

&lt;p&gt;The graph is deliberately tiny: one &lt;code&gt;agent&lt;/code&gt; node that reasons, one &lt;code&gt;tools&lt;/code&gt; node that retrieves, and a conditional edge that loops between them until the model stops asking for tools.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/graph/builder.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langgraph.graph&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;END&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StateGraph&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langgraph.prebuilt&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ToolNode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools_condition&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_graph&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StateGraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AgentState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent_node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_entry_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# ReAct: if the model emits tool_calls, go to `tools`; otherwise END.
&lt;/span&gt;    &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ToolNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TOOLS&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_conditional_edges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools_condition&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tools_condition&lt;/code&gt; and &lt;code&gt;ToolNode&lt;/code&gt; are LangGraph prebuilts that do the unglamorous work: inspect the last message for &lt;code&gt;tool_calls&lt;/code&gt;, route accordingly, execute the tools, and append &lt;code&gt;ToolMessage&lt;/code&gt;s back into state. You wire the loop; they run it.&lt;/p&gt;

&lt;p&gt;State is a single shared message log with a reducer that &lt;em&gt;appends&lt;/em&gt; rather than replaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/graph/state.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TypedDict&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_core.messages&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseMessage&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langgraph.graph.message&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;add_messages&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AgentState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TypedDict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BaseMessage&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;add_messages&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;add_messages&lt;/code&gt; is the reducer. Every node returns &lt;code&gt;{"messages": [...]}&lt;/code&gt; and LangGraph merges it into the running log — no manual list-shuffling, and it's what makes the agent⇄tools loop accumulate context correctly.&lt;/p&gt;

&lt;p&gt;The agent node binds the tools and calls the model. Note &lt;code&gt;bind_tools&lt;/code&gt; is conditional — flip RAG off and the exact same node degrades to a plain single-shot chat call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/graph/nodes/agent.py
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;agent_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AgentState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;llm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_llm&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;get_settings&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;rag_enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;llm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind_tools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TOOLS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;SystemMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SYSTEM_PROMPT&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ainvoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the tool itself is an ordinary &lt;code&gt;@tool&lt;/code&gt;-decorated function. The docstring is not documentation — it's the prompt the model reads to decide &lt;em&gt;when&lt;/em&gt; to call it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/graph/tools.py
&lt;/span&gt;&lt;span class="nd"&gt;@tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_docs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Search internal docs for content relevant to the question.
    When the user asks about the project/system/docs, call this first.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_vector_store&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;similarity_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;get_settings&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;rag_top_k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;] (source: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;page_content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No relevant documents found.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning a &lt;code&gt;[1] (source: ...)&lt;/code&gt; structure isn't cosmetic — it's how the model can cite sources in its final answer, which is the difference between a demo and something people trust.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The OpenAI-compatible surface
&lt;/h2&gt;

&lt;p&gt;Here's the lever that makes everything else cheap: the agent speaks OpenAI's wire format. The router turns an incoming &lt;code&gt;/v1/chat/completions&lt;/code&gt; request into graph input and the graph's output back into an OpenAI response.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/api/router.py
&lt;/span&gt;&lt;span class="nd"&gt;@router.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/chat/completions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;chat_completions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ChatCompletionRequest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_graph&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;inputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;to_langchain_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ainvoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_final_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;make_completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;served_model_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;StreamingResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;graph_to_openai_sse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;served_model_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;media_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text/event-stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the response matches OpenAI's schema (including SSE streaming chunks), &lt;strong&gt;Open WebUI thinks it's talking to OpenAI&lt;/strong&gt;. You point its &lt;code&gt;openaiBaseUrl&lt;/code&gt; at this service and your custom RAG agent shows up as a selectable model. No frontend work.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. One gateway, many models
&lt;/h2&gt;

&lt;p&gt;LangGraph nodes never name a provider. They call one factory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/llm/client.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ChatOpenAI&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;streaming&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ChatOpenAI&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_settings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ChatOpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;litellm_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# gateway, not a provider
&lt;/span&gt;        &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;litellm_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;default_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;default_temperature&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;streaming&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;streaming&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;base_url&lt;/code&gt; points at a &lt;strong&gt;LiteLLM gateway&lt;/strong&gt;, not at any specific vendor. LiteLLM exposes an OpenAI-compatible endpoint and fans out to whatever its &lt;code&gt;model_list&lt;/code&gt; says — a hosted API today, self-hosted vLLM tomorrow. Migrating off a paid API to an in-cluster GPU model becomes a &lt;em&gt;gateway config edit&lt;/em&gt;; this Python file never changes.&lt;/p&gt;

&lt;p&gt;There's one deliberate escape hatch — when the gateway is down locally, point straight at Ollama's OpenAI-compatible endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ollama&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ChatOpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ollama_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ollama&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ollama_chat_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same &lt;code&gt;ChatOpenAI&lt;/code&gt; class, different &lt;code&gt;base_url&lt;/code&gt;. The OpenAI-compatible interface shows up &lt;em&gt;three&lt;/em&gt; times in this architecture — inbound API, gateway, and local fallback — and that consistency is the whole trick.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Tracing in one line
&lt;/h2&gt;

&lt;p&gt;A multi-node graph with a tool loop is opaque when it goes wrong. Did the model skip the tool? Retrieve garbage? Loop twice? Langfuse's LangChain callback captures the entire run — every node transition, tool call, and LLM call — as a single nested trace.&lt;/p&gt;

&lt;p&gt;The integration is genuinely one object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/obs/langfuse.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;functools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;lru_cache&lt;/span&gt;

&lt;span class="nd"&gt;@lru_cache&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_langfuse_handler&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_settings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;langfuse_public_key&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;langfuse_secret_key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# no keys → tracing silently disabled (safe for local/POC)
&lt;/span&gt;    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langfuse.langchain&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CallbackHandler&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;CallbackHandler&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Heads-up for the SDK version churn: on &lt;strong&gt;Langfuse SDK v3+&lt;/strong&gt; the import is &lt;code&gt;from langfuse.langchain import CallbackHandler&lt;/code&gt;, and the handler reads &lt;code&gt;LANGFUSE_PUBLIC_KEY&lt;/code&gt; / &lt;code&gt;LANGFUSE_SECRET_KEY&lt;/code&gt; / &lt;code&gt;LANGFUSE_HOST&lt;/code&gt; from the environment — you don't pass keys to the constructor anymore. This tripped up a lot of v2 tutorials.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then attach it per request via the graph &lt;code&gt;config&lt;/code&gt; — which is also where you stamp user/session metadata so traces are filterable in the Langfuse UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/api/router.py
&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_langfuse_handler&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;callbacks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metadata&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;langfuse_user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anonymous&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;langfuse_session_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no-session&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;langfuse_tags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;served_model_name&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Passing the handler through &lt;code&gt;config["callbacks"]&lt;/code&gt; (rather than baking it into the LLM client) means it propagates down the &lt;em&gt;entire&lt;/em&gt; graph automatically. One request → one trace → every step visible.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this buys you
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;How it's handled&lt;/th&gt;
&lt;th&gt;Why it scales&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Frontend integration&lt;/td&gt;
&lt;td&gt;OpenAI-compatible API&lt;/td&gt;
&lt;td&gt;Any OpenAI client works unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model choice&lt;/td&gt;
&lt;td&gt;LiteLLM gateway behind &lt;code&gt;ChatOpenAI&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Swap providers via config, not code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent logic&lt;/td&gt;
&lt;td&gt;LangGraph &lt;code&gt;StateGraph&lt;/code&gt; + prebuilts&lt;/td&gt;
&lt;td&gt;ReAct loop in ~10 lines, extensible to multi-agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability&lt;/td&gt;
&lt;td&gt;Langfuse callback via graph &lt;code&gt;config&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;One trace per request, zero per-node wiring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local dev&lt;/td&gt;
&lt;td&gt;Ollama fallback through same interface&lt;/td&gt;
&lt;td&gt;No gateway needed to hack offline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these pieces is exotic. The point is the &lt;strong&gt;seams&lt;/strong&gt;: an OpenAI boundary on the outside, a gateway boundary on the model side, and a callback boundary for observability. Get the seams right and the agent in the middle stays small and swappable.&lt;/p&gt;

&lt;p&gt;The same skeleton extends cleanly to a supervisor/worker multi-agent graph, a Postgres checkpointer for persistent threads, and an in-cluster vLLM model — each is an additive change behind one of those seams. But that's a follow-up post.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with LangGraph, LangChain, LiteLLM, Qdrant, and Langfuse. If you're running LangGraph in production and want to compare notes on deployment patterns, reach out.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>langchain</category>
      <category>llm</category>
      <category>python</category>
      <category>kubernetes</category>
    </item>
  </channel>
</rss>
