| name | 06d-appkit-agent-app-proxy |
| description | Wire an AppKit frontend (Databricks App) to a separate Agent App backend (Databricks App hosting mlflow.genai.agent_server or ResponsesAgent) with OBO token forwarding via x-forwarded-access-token, app-to-app service principal auth, dual-format SSE streaming, and the /api/chat contract. Use this skill when the agent is deployed as its own Databricks App — not as a Model Serving endpoint. Triggers on "agent app backend", "two apps", "appkit plus agent app", "proxy agent app", "agent-backend resource", "x-forwarded-access-token", "migrate agent to apps", "agent app proxy".
|
| license | Apache-2.0 |
| compatibility | Node.js v22+, Databricks CLI >= 0.295.0, mlflow >= 3.6, AppKit >= 0.x |
| applies_to | [{"variant":"4-agentapp-plus-appkit"}] |
| when | AppKit scaffold exists (01-03) and the agent is deployed as its own Databricks App via Track A (07-deploy-and-query, target=databricks_apps). |
| prereqs | ["Agent App deployed and RUNNING (Track A Prompt 17)","AppKit App has CAN_USE on the Agent App (resource binding)","Node.js v22+, Databricks CLI >= 0.295.0"] |
| produces | ["apps_lakebase/$APP_NAME/server/agent-proxy.ts","Edits to apps_lakebase/$APP_NAME/server/server.ts (extend + /api/chat)","Edits to apps_lakebase/$APP_NAME/app.yaml (AGENT_APP_URL via valueFrom)","Edits to apps_lakebase/$APP_NAME/databricks.yml (app resource block)"] |
| fields_read | ["agent.backend_url","agent.stream_format"] |
| allowed-tools | Bash(databricks:*) Bash(npm:*) Bash(curl:*) Bash(node:*) Read |
| clients | ["ide_cli","genie_code"] |
| bundle_resource | apps |
| deploy_verb | apps_deploy |
| deploy_note | The proxy handler + OBO header forwarding (`x-forwarded-access-token`, `x-app-user-email`) is server-side code — **client-agnostic**, runs identically on both clients. IDE: `databricks apps get/bundle validate --profile $PROFILE`, `npm run build`, then `databricks apps deploy --profile $PROFILE`. Genie Code: run `apps get` / `bundle validate` via `runDatabricksCli` (omit `--profile`; a targetless `bundle validate` may need `--target dev`); local `npm run build` is an IDE convenience (server-side build on deploy); deploy per `03-appkit-deploy`. Verify via browser + `apps logs` or the OAuth-session test — `auth token` + raw Bearer `curl` to `/invocations` is hard-blocked on Genie Code.
|
| coverage | full |
| metadata | {"author":"prashanth subrahmanyam","version":"1.1.0","domain":"apps","role":"serving-wiring","standalone":false,"last_verified":"2026-06-02","volatility":"medium","upstream_sources":[{"name":"databricks-agent-skills/databricks-model-serving","repo":"databricks/databricks-agent-skills","paths":"[Truncated]","relationship":"extended","last_synced":"2026-04-27","sync_commit":"manifest-v2-2026-04-22"},{"name":"databricks-agent-skills/databricks-apps","repo":"databricks/databricks-agent-skills","paths":"[Truncated]","relationship":"extended","last_synced":"2026-04-27","sync_commit":"manifest-v2-2026-04-22"}]} |
Wire AppKit Frontend to Agent App Backend (2-Apps Canonical)
Proxy requests from an AppKit Databricks App (frontend) to a separate Agent Databricks App (backend) with end-user identity forwarded via x-forwarded-access-token. This is the canonical wiring for Variant 4 of the workshop — the 2-Apps pattern where agent runtime and product UI are on independent deployment cadences.
When to Use
- The agent is deployed as its own Databricks App (per Track A 07-deploy-and-query with
target=databricks_apps).
- You want a rich AppKit dashboard (Lakebase tables, admin views, custom React) plus a separate agent service.
- You need user-scoped tool calls (Lakebase row-level auth, Genie, per-user UC grants) — OBO is non-negotiable.
- You accept two deployables and two deploy cadences.
When NOT to Use
- Agent deployed to Model Serving / Agent Serving endpoint — use
06-appkit-serving-wiring. URL shape, auth model, and resource type differ.
- Supervisor API (Beta) hosted agent — use
06b-appkit-supervisor-wiring. That path uses a Python sidecar, not a separate Databricks App.
- Agent in the same AppKit process (Node-native) — keep the agent in-process and skip this proxy skill. The older
06c-appkit-integrated-agent path is not bundled in this template.
- Template-only chat app (Variant 3) — no AppKit at all; just use a Databricks Apps template directly.
Architecture
flowchart LR
Browser["Browser"]
AppKit["AppKit App (Node/Express)"]
AgentApp["Agent App (FastAPI + mlflow.genai.agent_server)"]
Tools["UC Functions / Genie / Vector Search / Lakebase"]
Browser -->|"POST /api/chat (SSE)"| AppKit
AppKit -->|"POST /invocations<br/>Authorization: Bearer SP<br/>x-forwarded-access-token"| AgentApp
AgentApp -->|"get_user_workspace_client(request)"| Tools
Two independent auth layers on every request:
- App-to-App: AppKit service principal has
CAN_USE on the Agent App (carried as Authorization: Bearer <SP>).
- End-user OBO: forwarded verbatim as
x-forwarded-access-token and consumed by the agent handler.
See references/obo-forwarding.md for the full contract.
Header Contract (Canonical)
Inbound headers (browser → AppKit App)
Databricks Apps canonical user headers, set by the Apps platform on every request:
x-forwarded-email — the end user's email (canonical attribute used as MLflow AssessmentSource.source_id).
x-forwarded-preferred-username — the user's preferred username (often equals the email).
x-forwarded-user — the user's stable identifier (workspace user id).
x-forwarded-access-token — the downscoped OBO token for user-scoped Databricks API calls (only present when the App declares user_api_scopes).
x-forwarded-user-info is not a canonical Databricks Apps header and must not be used. Earlier internal code paths assumed it existed; it does not. Always read the four canonical headers above.
Outbound header (AppKit App → Agent App)
Pathway-C AppKit proxies must set x-app-user-email on outbound requests to the Agent App. The agent uses this value as AssessmentSource.source_id for MLflow feedback. Do not infer originating user identity from the app-to-app OAuth Authorization: Bearer token — that token represents the AppKit App service principal hop, not the originating end user.
Concretely, in the proxy handler (Step 2 below):
const email = req.headers["x-forwarded-email"];
if (typeof email === "string" && email.length > 0) {
headers.set("x-app-user-email", email);
}
The Agent App's @invoke / @stream handler then reads x-app-user-email from its incoming request and passes it as AssessmentSource(source_type="HUMAN", source_id=<email>) when it logs feedback to MLflow. See Track A 04-authentication for the agent-side contract.
SSE Synthesis Debt (workspace_sse_guardrail)
The Sonnet endpoints used in the workshop are subject to an AI-Gateway output guardrail that trips on streaming responses. When the selected agent_chat endpoint has streaming_ok == false in endpoint_guardrail_audit, this proxy MUST call the Agent App with stream: false, take the unary /invocations JSON response, and synthesize the SSE stream the frontend expects — typically one response.output_text.delta followed by response.completed and [DONE]. The dual extractor in references/dual-format-streaming.md consumes that synthesized stream identically to a real one.
debt: workspace_sse_guardrail is canonical while the selected agent_chat endpoint has streaming_ok == false. This is "accepted permanent" only in the sense that the workshop has no admin-ticket or external-key requirement. The debt still has remove_when and must be audited by vibecoding-state.audit_debts.
Operator obligations:
- The synthesized-SSE branch MUST be tagged
debt: workspace_sse_guardrail in Globals.productized_debts[], with a remove_when predicate referencing endpoint_guardrail_audit[llm_role_endpoints.agent_chat.endpoint].streaming_ok == true.
- Do not assume the workaround is invisible just because no admin ticket is filed.
audit_debts (see vibecoding-state SKILL.md) re-evaluates remove_when on every audit run; once the upstream guardrail flips, the debt's debt_lifted evaluation flips and the synthesis branch must be removed.
- Ship a one-line revert path gated on
debt_lifted (e.g. return upstream.body instead of synthesizing) so the proxy reverts to true streaming without further engineering when the policy changes.
Without this discipline, the proxy productizes the workaround forever — Track A users get non-streaming UX even after the underlying policy is fixed, because no operator will know to delete the synthesized-SSE branch.
Request Body Contract (Dual Shape, Normalized)
The proxy MUST accept and normalize both of the request body shapes that AppKit chat clients and Track A authoring patterns produce:
- Responses-style —
{ "input": ... } (the shape mlflow.genai.agent_server Responses adapters expect).
- Chat-style —
{ "messages": [{ "role": "user", "content": "..." }, ...] } (the OpenAI-compatible shape used by the minimal hook in Step 4).
Anything else MUST be rejected at the proxy (before any upstream call) with HTTP 400 and a JSON body:
{ "error": "invalid_body_shape", "accepted": ["input", "messages"] }
Concretely, in the proxy handler:
type Body = { input?: unknown; messages?: unknown };
const body = (req.body ?? {}) as Body;
const hasInput = body.input !== undefined;
const hasMessages = Array.isArray(body.messages);
if (!hasInput && !hasMessages) {
res.status(400).json({ error: "invalid_body_shape", accepted: ["input", "messages"] });
return;
}
const upstreamBody = hasInput
? { input: body.input }
: { messages: body.messages };
Why the dual shape matters: an earlier iteration of the proxy assumed only {messages} and dropped {input} callers silently — the agent received a malformed body and returned an empty stream that the dual extractor rendered as a blank UI. Rejecting with invalid_body_shape makes the contract violation observable instead of silent.
See references/dual-format-streaming.md for the streaming counterpart of this contract — dual request shapes here, dual response shapes there.
Before You Begin
Prerequisites — verify these before proceeding:
-
Agent App is deployed and in RUNNING state:
databricks apps get "$AGENT_APP_NAME" --profile "$PROFILE" --output json | jq -r '.status.state'
Expect RUNNING. If not, complete Track A Prompt 17 before continuing.
-
Agent App URL is known:
databricks apps get "$AGENT_APP_NAME" --profile "$PROFILE" --output json | jq -r '.url'
-
Agent App accepts /invocations:
AGENT_URL=$(databricks apps get "$AGENT_APP_NAME" --profile "$PROFILE" --output json | jq -r '.url')
TOKEN=$(databricks auth token --profile "$PROFILE" --output json | jq -r '.access_token')
curl -s -X POST "${AGENT_URL%/}/invocations" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Hello"}]}' | head -c 500
Non-empty response with HTTP 200 means the agent is reachable. A 401 means your SP lacks CAN_USE (see Step 1). A 404 means the agent wasn't authored with /invocations (it must use mlflow.genai.agent_server or AgentServer.serve() — see Track A 02-agent-framework).
-
Node.js v22+ and Databricks CLI >= 0.295.0:
node --version
databricks --version
Working in Genie Code (client routing)
The proxy handler and OBO forwarding (Step 2) are server-side code — identical on both clients. Only the toolchain commands and the deployed-app probes differ. $AGENT_APP_NAME / $APPKIT_APP_NAME / $PROFILE / $AGENT_APP_URL resolve from .vibecoding-state.md when a prior phase wrote them (don't re-derive). Apply these substitutions:
| IDE/CLI (as written) | Genie Code substitution |
|---|
databricks apps get … --profile $PROFILE (Prereqs 1–2) | run via runDatabricksCli (read-tier), omit --profile |
databricks auth token + curl … /invocations -H "Authorization: Bearer …" (Prereq 3) | auth token is hard-blocked and raw Bearer is rejected — confirm reachability from the deployed AppKit proxy (Step 6) or via the OAuth-session test in 03-appkit-deploy; for a quick SP-only check use the SDK executeCode (w.config + requests) |
databricks bundle validate --profile $PROFILE (Step 1d) | run via runDatabricksCli (omit --profile); if a targetless validate is guardrail-blocked, pass --target dev |
npm run build gates (Steps 2, 3, 4, 5) | IDE-only convenience — no local Node toolchain. Skip; the platform builds server-side on deploy, errors surface in databricks apps logs <name> |
npm run dev | not available — verify on the deployed app |
databricks apps deploy … (Step 6a) | see the 03-appkit-deploy deploy-routing contract (runDatabricksCli, else SDK w.apps.deploy(... SNAPSHOT)) |
test-agent-app-proxy.sh four-probe E2E (Step 6b) | runs against deployed apps; on Genie Code drive its databricks/curl calls via runDatabricksCli / executeCode (omit --profile). The browser check (Step 6c) is the simplest manual verify. |
Paths are relative to apps_lakebase/$APP_NAME — inside your git-cloned workshop project (artifact_root) on Genie Code, never the read-only .assistant/skills copy and never /tmp. See skills/genie-code-environment for the full manifest.
Decision Defaults
| Decision | Default | Rationale |
|---|
| Chat route | /api/chat (SSE POST) | Matches the Track A agent app contract and keeps the frontend hook stable across variants |
| Frontend hook | Custom useChat-shaped hook (raw fetch + ReadableStream) | Serving plugin's useServingStream assumes a Serving endpoint URL shape |
| App-to-App auth | AppKit SP via CAN_USE on the Agent App | Explicit resource binding; auditable |
| End-user auth | Forward x-forwarded-access-token verbatim | What the agent's get_user_workspace_client(request) expects |
| Streaming format | Dual parser (Responses API + OpenAI chat chunks) | mlflow.genai.agent_server emits both depending on authoring pattern |
| Timeout | 120000 ms (2 min) | Agents can be slow; first-request cold-start ~20 s |
| Serving plugin? | No — use vanilla server() + server.extend() | Serving plugin's resource shape is wrong for Apps backends |
| Agent App URL env var | AGENT_APP_URL via valueFrom: agent-backend | Explicit, avoids platform-derived names that vary across CLI versions |
Step 0: Workspace alignment preflight
app: resource binding (Step 1) only works when the AppKit App and the Agent App live in the same Databricks workspace. Cross-workspace bindings are silently dropped at deploy time. Run this preflight before declaring the resource — it takes 30 seconds and prevents a multi-hour migration loop.
profile_host() {
local profile="$1"
databricks auth profiles --skip-validate --output json \
| jq -r --arg p "$profile" '.profiles[] | select(.name == $p) | .host'
}
APPKIT_PROFILE="${APPKIT_PROFILE:-$PROFILE}"
AGENT_PROFILE="${AGENT_PROFILE:-$PROFILE}"
APPKIT_HOST=$(profile_host "$APPKIT_PROFILE")
AGENT_HOST=$(profile_host "$AGENT_PROFILE")
if [ -z "$APPKIT_HOST" ] || [ -z "$AGENT_HOST" ]; then
echo "FAIL: could not resolve both Databricks profile hosts."
echo " APPKIT_PROFILE=$APPKIT_PROFILE -> ${APPKIT_HOST:-<missing>}"