diff --git a/dev/README.md b/dev/README.md
index 12e68cc59..05466e407 100644
--- a/dev/README.md
+++ b/dev/README.md
@@ -1 +1,33 @@
-ADK development utilities such as Spring REST server for agent.
\ No newline at end of file
+ADK development utilities such as Spring REST server for agent.
+
+## Serving the dev UI
+
+The UI and its assets are served under `/dev-ui/`, and both `/` and `/dev-ui`
+redirect there, keeping the query string. The assets are not served from the
+origin root: `/adk_favicon.svg` and the like return 404, and only the `/dev-ui/`
+form resolves.
+
+## Behind a reverse proxy
+
+When a gateway publishes this server under a path prefix and strips it, tell the
+server the address browsers actually reach it on:
+
+```properties
+adk.web.backend-url=https://gateway.example.com/my-app
+```
+
+That one value does both halves: the entry redirect carries the prefix, and the
+UI's own API calls go back through it. Nothing has to be forwarded by the proxy,
+and nothing is read from the request.
+
+It must be an absolute URL. The UI reads a value without a scheme as the host of
+its live/websocket connection, so a bare `/my-app` makes that socket dial a host
+named `my-app`.
+
+Include any `server.servlet.context-path` in the value: in the redirect it
+replaces the context path rather than stacking on it.
+
+Leave it unset and nothing changes: the redirect is unprefixed and the bundled
+`backendUrl` is served as it always was. A deployment that already restores the
+prefix with Spring's own `server.forward-headers-strategy=framework` keeps
+working that way; setting this property takes precedence over it.
diff --git a/dev/src/main/java/com/google/adk/web/AdkWebServer.java b/dev/src/main/java/com/google/adk/web/AdkWebServer.java
index b321cef27..f75f3afc7 100644
--- a/dev/src/main/java/com/google/adk/web/AdkWebServer.java
+++ b/dev/src/main/java/com/google/adk/web/AdkWebServer.java
@@ -25,6 +25,7 @@
import com.google.adk.memory.InMemoryMemoryService;
import com.google.adk.sessions.BaseSessionService;
import com.google.adk.sessions.InMemorySessionService;
+import com.google.adk.web.config.DevUiAssets;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -51,6 +52,9 @@ public class AdkWebServer implements WebMvcConfigurer {
@Value("${adk.web.ui.dir:#{null}}")
private String webUiDir;
+ @Value("${adk.web.backend-url:}")
+ private String backendUrl;
+
@Bean
public BaseSessionService sessionService() {
// TODO: Add logic to select service based on config (e.g., DB URL)
@@ -109,48 +113,41 @@ public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter(
}
/**
- * Configures resource handlers for serving static content (like the Dev UI). Maps requests
- * starting with "/dev-ui/" to the directory specified by the 'adk.web.ui.dir' system property.
+ * Maps requests under "/dev-ui/" to the directory named by the 'adk.web.ui.dir' property, or to
+ * the bundled copy on the classpath when that is unset.
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
- if (webUiDir != null && !webUiDir.isEmpty()) {
- // Ensure the path uses forward slashes and ends with a slash
- String location = webUiDir.replace("\\", "/");
- if (!location.startsWith("file:")) {
- location = "file:" + location; // Ensure file: prefix
- }
- if (!location.endsWith("/")) {
- location += "/";
- }
- log.debug("Mapping URL path /** to static resources at location: {}", location);
- registry
- .addResourceHandler("/**")
- .addResourceLocations(location)
- .setCachePeriod(0)
- .resourceChain(true);
-
- } else {
- log.debug(
- "System property 'adk.web.ui.dir' or config 'adk.web.ui.dir' is not set. Mapping URL path"
- + " /** to classpath:/browser/");
- registry
- .addResourceHandler("/**")
- .addResourceLocations("classpath:/browser/")
- .setCachePeriod(0)
- .resourceChain(true);
- }
+ String location = DevUiAssets.assetRoot(webUiDir);
+ log.debug("Mapping URL path /dev-ui/** to static resources at location: {}", location);
+ registry
+ .addResourceHandler("/dev-ui/**")
+ .addResourceLocations(location)
+ .setCachePeriod(0)
+ .resourceChain(true);
}
/**
- * Configures simple automated controllers: - Redirects the root path "/" to "/dev-ui". - Forwards
- * requests to "/dev-ui" to "/dev-ui/index.html" so the ResourceHandler serves it.
+ * Configures simple automated controllers: "/" and "/dev-ui" both redirect to the UI, at {@code
+ * adk.web.backend-url}'s path when that is set, and it forwards to index.html. The trailing slash
+ * is required: index.html declares a {@code }, so served from "/dev-ui" the app
+ * resolves its own router path to "dev-ui" and matches none of its routes. The query string is
+ * carried across because the UI selects its agent from {@code ?app=}.
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
- registry.addRedirectViewController("/", "/dev-ui");
- registry.addViewController("/dev-ui").setViewName("forward:/index.html");
- registry.addViewController("/dev-ui/").setViewName("forward:/index.html");
+ String prefix = DevUiAssets.pathOf(backendUrl);
+ // The configured value is the public base, so do not stack the context path on it.
+ boolean contextRelative = prefix.isEmpty();
+ registry
+ .addRedirectViewController("/", prefix + "/dev-ui/")
+ .setKeepQueryParams(true)
+ .setContextRelative(contextRelative);
+ registry
+ .addRedirectViewController("/dev-ui", prefix + "/dev-ui/")
+ .setKeepQueryParams(true)
+ .setContextRelative(contextRelative);
+ registry.addViewController("/dev-ui/").setViewName("forward:/dev-ui/index.html");
}
/**
diff --git a/dev/src/main/java/com/google/adk/web/config/DevUiAssets.java b/dev/src/main/java/com/google/adk/web/config/DevUiAssets.java
new file mode 100644
index 000000000..2bd341654
--- /dev/null
+++ b/dev/src/main/java/com/google/adk/web/config/DevUiAssets.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.web.config;
+
+import com.google.common.base.CharMatcher;
+import java.net.URI;
+import java.net.URISyntaxException;
+import org.jspecify.annotations.Nullable;
+import org.springframework.core.io.ResourceLoader;
+
+/**
+ * Where the dev UI's static assets live. Shared so the resource handler and the runtime-config
+ * endpoint resolve the same location; normalizing {@code adk.web.ui.dir} separately in each would
+ * diverge silently.
+ */
+public final class DevUiAssets {
+
+ /** The runtime config, relative to the asset root. */
+ public static final String RUNTIME_CONFIG_PATH = "assets/config/runtime-config.json";
+
+ private static final String CLASSPATH_ROOT = ResourceLoader.CLASSPATH_URL_PREFIX + "/browser/";
+
+ /**
+ * The asset root: {@code webUiDir} as a {@code file:} URL when set, else the bundled classpath
+ * copy. Always ends in a slash.
+ */
+ public static String assetRoot(@Nullable String webUiDir) {
+ if (webUiDir == null || webUiDir.isEmpty()) {
+ return CLASSPATH_ROOT;
+ }
+ String location = webUiDir.replace("\\", "/");
+ if (!location.startsWith("file:")) {
+ location = "file:" + location;
+ }
+ return location.endsWith("/") ? location : location + "/";
+ }
+
+ /** The location of a single asset, given relative to the asset root. */
+ public static String assetLocation(@Nullable String webUiDir, String relativePath) {
+ return assetRoot(webUiDir) + relativePath;
+ }
+
+ /**
+ * The path a gateway strips, taken from {@code backendUrl} on a best-effort basis, or empty when
+ * there is none to take. This is what the UI's entry redirect has to carry, so it stays
+ * percent-encoded and never starts with a second slash, which a browser would read as a host.
+ */
+ public static String pathOf(@Nullable String backendUrl) {
+ if (backendUrl == null) {
+ return "";
+ }
+ String trimmed = backendUrl.trim();
+ if (trimmed.isEmpty()) {
+ return "";
+ }
+ String path;
+ try {
+ // Raw: the result goes into a Location header, so decoding it here would re-encode wrongly.
+ path = new URI(trimmed).getRawPath();
+ } catch (URISyntaxException e) {
+ return "";
+ }
+ if (path == null) {
+ return "";
+ }
+ String collapsed = path.replaceAll("^/+", "/");
+ return CharMatcher.is('/').trimTrailingFrom(collapsed);
+ }
+
+ private DevUiAssets() {}
+}
diff --git a/dev/src/main/java/com/google/adk/web/controller/RuntimeConfigController.java b/dev/src/main/java/com/google/adk/web/controller/RuntimeConfigController.java
new file mode 100644
index 000000000..1e3f0c12d
--- /dev/null
+++ b/dev/src/main/java/com/google/adk/web/controller/RuntimeConfigController.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.web.controller;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.adk.web.config.DevUiAssets;
+import com.google.common.base.CharMatcher;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.ResourceLoader;
+import org.springframework.http.CacheControl;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * Serves the dev UI's runtime configuration, shadowing the copy bundled in the static assets so
+ * {@code adk.web.backend-url} can point the UI at the address browsers reach this server on. The
+ * bundled document is merged rather than replaced, so keys the UI gains in a later bundle survive.
+ */
+@RestController
+public class RuntimeConfigController {
+
+ private static final Logger log = LoggerFactory.getLogger(RuntimeConfigController.class);
+
+ private static final Pattern ABSOLUTE_URL = Pattern.compile("^https?://.+");
+
+ private final ResourceLoader resourceLoader;
+ private final ObjectMapper objectMapper;
+ private final @Nullable String webUiDir;
+ private final String backendUrl;
+
+ /** Reads the bundled config through {@code resourceLoader}, or from {@code webUiDir} if set. */
+ @Autowired
+ public RuntimeConfigController(
+ ResourceLoader resourceLoader,
+ ObjectMapper objectMapper,
+ @Value("${adk.web.ui.dir:#{null}}") @Nullable String webUiDir,
+ @Value("${adk.web.backend-url:}") String backendUrl) {
+ this.resourceLoader = resourceLoader;
+ this.objectMapper = objectMapper;
+ this.webUiDir = webUiDir;
+ String configured = backendUrl.trim();
+ // A trailing slash would double up: the UI appends paths that already start with one.
+ String normalized = CharMatcher.is('/').trimTrailingFrom(configured);
+ if (configured.isEmpty()) {
+ this.backendUrl = "";
+ } else if (ABSOLUTE_URL.matcher(normalized).matches()) {
+ this.backendUrl = normalized;
+ } else {
+ // Served exactly as configured, so an explicit value is never silently discarded.
+ this.backendUrl = configured;
+ log.warn(
+ "adk.web.backend-url should be an absolute URL, but is \"{}\". The dev UI reads a value"
+ + " without a lower-case http:// or https:// scheme as the host of its"
+ + " live/websocket connection.",
+ configured);
+ }
+ }
+
+ /** Serves the bundled config with {@code backendUrl} taken from configuration when set. */
+ @GetMapping(
+ value = "/dev-ui/" + DevUiAssets.RUNTIME_CONFIG_PATH,
+ produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity