Deploying Omnigent on Databricks Apps

June 29, 2026 · View on GitHub

This directory deploys the Omnigent server to Databricks Apps via Databricks Asset Bundles:

  • Lakebase (managed PostgreSQL) — the database for every store.
  • UC Volumes — the artifact store for agent bundles and executor storage snapshots.

Most Databricks users want the managed offering instead. Omnigent on Databricks (Beta) runs the server for you, wired to workspace identity, Foundation Models, AI Gateway, and MLflow Tracing out of the box. Enable the Omnigent preview in your workspace settings and follow the quickstart there. Use this directory only when you need to self-manage the deployment: the managed service is not in your region yet, or you need control it does not expose today (custom YAML policies, bring-your-own provider API keys, custom egress controls).

The orchestrator at deploy.py builds the wheels, generates an app pyproject.toml + uv.lock, and then runs databricks bundle deploy + bundle run against the bundle config in databricks.yml. App config (Lakebase, UC volume) lives declaratively in databricks.yml — adding or removing a resource is a YAML edit, not a Python SDK call.

Runs unchanged from a laptop. Re-runnable; every step is idempotent.

Prerequisites

  1. A Databricks workspace with Databricks Apps, Lakebase, and UC Volumes enabled.
  2. The Databricks CLI installed and authenticated. Either a CLI profile (DATABRICKS_CONFIG_PROFILE=<profile>) or env-based auth (DATABRICKS_HOST + DATABRICKS_CLIENT_ID + DATABRICKS_CLIENT_SECRET).
  3. The repo's local venv with the databricks extra: uv sync --extra databricks (use uv, not global pip).
  4. Permissions to create or use:
    • a Lakebase project (one per app — do not share with other apps);
    • a UC volume whose parent catalog/schema can grant access to the app service principal;
    • (optional) Databricks secrets for LLM API keys.

Set your workspace URL in databricks.yml under targets.prod.workspace.host (it ships as a https://example.databricks.com placeholder; DAB reads workspace.host before resolving variables, so it must be a literal).

One-time bootstrap

1. Lakebase project (one per app — never share)

Reusing a shared autoscaling project causes the migrate-on-boot hook to fail with "permission denied for table agents" because the tables are owned by whoever ran migrations first. Always start fresh:

from databricks.sdk import WorkspaceClient
from databricks.sdk.service.postgres import Project

wc = WorkspaceClient(profile="<your-profile>")
wc.postgres.create_project(project=Project(), project_id="omnigent")

branch = "projects/omnigent/branches/production"
endpoint = f"{branch}/endpoints/primary"

import time
for _ in range(120):
    ep = wc.postgres.get_endpoint(name=endpoint)
    if ep.status and ep.status.current_state == "ACTIVE":
        break
    time.sleep(5)
else:
    raise TimeoutError(endpoint)

database = next(iter(wc.postgres.list_databases(parent=branch)))
print("database resource path:", database.name)

2. UC Volumes

CREATE SCHEMA IF NOT EXISTS main.omnigent;
CREATE VOLUME IF NOT EXISTS main.omnigent.artifacts;

The artifacts volume is referenced declaratively in databricks.yml (the app resource) via --var volume_name=….

3. First deploy — creates the app and its service principal

Run the Deploy command once. The first databricks bundle deploy creates the app and provisions its service principal (SP). This first pass will not pass its /health check yet: the SP has no Lakebase schema grants, so the migrate-on-boot hook fails with permission denied for schema public. That's expected — the next step grants those privileges.

4. Grant the app SP Lakebase privileges

Now that the app (and its SP) exist, grant the SP the public schema privileges Alembic needs, then re-run the deploy:

python deploy/databricks/grant_sp_perms.py \
    --app-name omnigent \
    --lakebase-endpoint projects/omnigent/branches/production/endpoints/primary \
    --database databricks_postgres \
    --profile <your-profile>

Note

Lakebase uses two spellings for the same database. The resource path uses a hyphenated slug — …/databases/databricks-postgres (what deploy.py --lakebase-database and databricks.yml want) — while the underlying PostgreSQL database name uses an underscore, databricks_postgres (what grant_sp_perms.py --database and the app's PGDATABASE use). Pass each form where shown.

After this one-time grant, re-running the deploy boots the app cleanly and /health returns 200. Subsequent redeploys are a single deploy.py invocation.

Deploy

uv run python deploy/databricks/deploy.py \
    --app-name omnigent \
    --profile <your-profile> \
    --lakebase-branch projects/omnigent/branches/production \
    --lakebase-database projects/omnigent/branches/production/databases/databricks-postgres \
    --volume-name main.omnigent.artifacts

The script builds wheels, classifies them by size, copies wheels into src/, regenerates src/pyproject.toml and src/uv.lock, runs databricks bundle deploy --target prod, runs databricks bundle run omnigent --target prod, and polls /health with backoff until 200.

All Omnigent wheels must fit under the Databricks Apps source snapshot limit (10 MB). If a wheel exceeds it, rebuild with --skip-web-ui or reduce the wheel size; uv lockfiles cannot point at UC Volume wheel paths because uv lock validates path sources locally.

Re-running is safe — every step is idempotent.

Tip

To lock against a private PyPI mirror or proxy instead of public PyPI, set UV_INDEX_URL before running deploy.py.

Smoke check

deploy.py polls /health automatically. To check other endpoints:

TOKEN="$(databricks auth token <your-profile> --output json \
    | python -c 'import json, sys; print(json.load(sys.stdin)["access_token"])')"

curl --http1.1 -fsS \
    -H "Authorization: Bearer ${TOKEN}" \
    https://<app>.databricksapps.com/health

How it works

Authentication

The app runs as a Databricks service principal. Credentials are managed automatically:

  • Lakebase — OAuth tokens generated via WorkspaceClient.postgres.generate_database_credential(), injected into every new SQLAlchemy connection via a class-level do_connect event hook in src/app.py.
  • UC Volumes — Workspace credentials used by the Databricks SDK (ambient in Apps).
  • TUI / API access — Browser-based OAuth using the databricks-cli OIDC client with PKCE.

The Databricks Apps proxy injects X-Forwarded-Email on every request, so the app pins OMNIGENT_AUTH_PROVIDER=header (see src/app.py).

Important

Header auth trusts the X-Forwarded-Email header verbatim. This is safe only because the Databricks Apps platform terminates auth at its proxy, strips any client-supplied copy of the header, and the app port is never reachable except through that proxy. Don't expose the app process directly (e.g. a port forward or alternate ingress that bypasses the proxy): a caller who can set the header themselves could then impersonate any user. If you front the app with anything other than the standard Apps proxy, ensure it sanitizes the header too.

Token lifecycle

Lakebase OAuth tokens expire after 60 minutes. The SQLAlchemy connection pool recycles connections every 5 minutes by default (configurable via AP_POOL_RECYCLE_SECONDS), ensuring fresh tokens on new connections.

Storage

ComponentBackendPurpose
Agent specs, tasks, conversationsLakebase (PostgreSQL)Durable metadata
Agent bundles, executor snapshotsUC VolumesBinary blob storage
DBOS workflow stateLakebase (same DB)Workflow recovery
Executor working dirsLocal ephemeral diskCache (restored from UC Volumes)

Configuration reference

Environment variables read by src/app.py:

VariableSourceDescription
PGHOSTDatabricks runtimeLakebase hostname
PGPORTDatabricks runtimeLakebase port (default 5432)
PGDATABASEDatabricks runtimeLakebase database name
PGUSERDatabricks runtimeLakebase user (service principal)
PGSSLMODEDatabricks runtimeSSL mode (default require)
AP_LAKEBASE_ENDPOINTapp resource valueFrom: postgresLakebase endpoint for token generation
AP_ARTIFACT_VOLUME_PATHapp resource valueFrom: artifact_volumeUC Volume path for artifacts
DATABRICKS_APP_PORTDatabricks runtimeApp port (default 8000)
AP_POOL_RECYCLE_SECONDSOptionalConnection pool recycle interval (default 300)

Multi-app safety — one bundle, many apps

The same bundle directory can deploy many apps (one per --app-name). Terraform can only delete or replace what is tracked in the state it loads, so the blast radius of a deploy is exactly that state file.

  • Remote state is per app. targets.<t>.workspace.root_path ends in ${var.app_name}, so --app-name X reads and writes state only under .bundle/omnigent/X. A deploy of X cannot mutate app Y.
  • The app resource's name is ${var.app_name}. If the loaded state tracks app X but you pass app_name=Y, terraform sees a name change and plans a destroy of X + create of Y. Never bind the bundle resource to one app and then deploy with a different --app-name.
  • The local cache at deploy/databricks/.databricks/bundle/<target>/ is per-target. Before deploying a different app on a target you've used before, drop it: rm -rf deploy/databricks/.databricks/bundle/<target> (it's only a cache; the per-app remote state is the source of truth).

If a bundle deploy plan ever shows a delete or replace of a databricks_app, abort and re-check the bind and --app-name — routine redeploys only ever update in place.

Common deploy modes

# Iterate without rebuilding wheels (reuses dist/; useful when you only
# changed app.py / app.yaml). Skips the clean-tree check.
uv run python deploy/databricks/deploy.py --skip-build --allow-dirty ...

# API-only deploy (drops the SPA from the main wheel).
uv run python deploy/databricks/deploy.py --skip-web-ui ...

Troubleshooting

SymptomCauseFix
Deploy refuses: "working tree has uncommitted changes" / "HEAD is not at origin/main"Clean-tree assertionCommit/stash, git checkout main && git pull, or pass --allow-dirty
bundle deploy fails: "Resource already managed by Terraform"App already bound to another bundle directoryRun from that directory, or unbind: databricks bundle deployment unbind omnigent
bundle deploy fails: "An app with the same name already exists"App exists but isn't bound to this bundle (or a stale per-target local cache from a different app made deploy.py skip the bind)rm -rf deploy/databricks/.databricks/bundle/<target>, then bind: databricks bundle deployment bind omnigent <app-name> --target <target> --auto-approve --var ...
App fails "Error installing packages"; /logz shows "Ignoring existing lockfile due to … exclude newer …" then a PyPI fetch timeoutThe Apps runtime pins a global uv exclude-newer cutoff; a lock generated without the matching option is re-resolved in-container, where PyPI is unreachableRead the cutoff from /logz ("change of exclude newer timestamp from X to Y") and redeploy with UV_EXCLUDE_NEWER=<cutoff> in the environment
permission denied for table agentsLakebase tables owned by wrong userConnect as the owner and DROP TABLE … CASCADE; redeploy
schema "dbos" already existsSame for the DBOS schemaDROP SCHEMA dbos CASCADE and redeploy
permission denied for schema publicApp SP missing schema grantsRun grant_sp_perms.py (one-time)
Field 'spec.role' cannot be emptyLakebase requires explicit role for extra databasesUse the project's default database; don't create extras
Deploy refuses because a wheel is over 10 MBuv app payload requires local wheel path sourcesRebuild with --skip-web-ui or reduce wheel size
App starts cleanly but the first agent request 403s on the artifact volumeApp SP has WRITE_VOLUME on the leaf but no USE_CATALOG / USE_SCHEMA on the parentsdeploy.py grants both automatically — for a fresh catalog, redeploy or grant manually via databricks grants update

Files in this directory

FilePurpose
deploy.pyOrchestrator. Single entry point.
databricks.ymlDAB bundle config. Declares the app + its resources.
build.shCleans static, builds the web UI, builds three wheels.
grant_sp_perms.pyOne-time Lakebase public schema grant for the app SP.
src/app.pyThe app process. SQLAlchemy do_connect token hook + Alembic-on-boot + uvicorn.
src/app.yamlApp startup config — command + env-var wiring.
src/pyproject.toml / src/uv.lockRegenerated per deploy; not committed (they pin the per-deploy wheel version).

See also