diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index 5623a2c7cd85..5ae901802fed 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -322,6 +322,43 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
},
+ async refresh(sessionID: string) {
+ const directory = sdk.directory
+ const client = sdk.client
+ const [, setStore] = globalSync.child(directory)
+ const key = keyFor(directory, sessionID)
+
+ touch(directory, setStore, sessionID)
+
+ const limit = meta.limit[key] ?? messagePageSize
+
+ const sessionReq = retry(() => client.session.get({ sessionID })).then((session) => {
+ if (!tracked(directory, sessionID)) return
+ const data = session.data
+ if (!data) return
+ setStore(
+ "session",
+ produce((draft) => {
+ const match = Binary.search(draft, sessionID, (s) => s.id)
+ if (match.found) {
+ draft[match.index] = data
+ return
+ }
+ draft.splice(match.index, 0, data)
+ }),
+ )
+ })
+
+ const messagesReq = loadMessages({
+ directory,
+ client,
+ setStore,
+ sessionID,
+ limit,
+ })
+
+ return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
+ },
async diff(sessionID: string) {
const directory = sdk.directory
const client = sdk.client
diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx
index c62baccba501..7db39747de8a 100644
--- a/packages/app/src/entry.tsx
+++ b/packages/app/src/entry.tsx
@@ -1,6 +1,7 @@
// @refresh reload
import { iife } from "@opencode-ai/util/iife"
+import { Router, type BaseRouterProps } from "@solidjs/router"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform"
@@ -102,7 +103,8 @@ const getCurrentUrl = () => {
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
- return location.origin
+ const base = (window as Window & { __OPENCODE_BASE_PATH?: string }).__OPENCODE_BASE_PATH ?? ""
+ return base ? `${location.origin}${base}` : location.origin
}
const getDefaultUrl = () => {
@@ -127,12 +129,14 @@ const platform: Platform = {
}
if (root instanceof HTMLElement) {
+ const base = (window as Window & { __OPENCODE_BASE_PATH?: string }).__OPENCODE_BASE_PATH ?? ""
+ const router = (props: BaseRouterProps) =>
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
render(
() => (
-
+
),
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 1b62b94294ce..daee4373ea97 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -632,6 +632,27 @@ export default function Page() {
}),
)
+ createEffect(
+ on([() => sdk.directory, () => params.id] as const, ([, id]) => {
+ if (!id) return
+ const base = (window as Window & { __OPENCODE_BASE_PATH?: string }).__OPENCODE_BASE_PATH
+ if (!base) return
+
+ const run = () => {
+ if (document.visibilityState !== "visible") return
+ const active = sync.data.session_status[id]?.type !== "idle"
+ if (!active) return
+ void sync.session.refresh(id)
+ void sync.session.todo(id)
+ }
+
+ run()
+ const timer = setInterval(run, 4000)
+
+ onCleanup(() => clearInterval(timer))
+ }),
+ )
+
createEffect(
on(
() => visibleUserMessages().at(-1)?.id,
diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts
index 0fe056f21f2f..9672d4b2e370 100644
--- a/packages/opencode/src/cli/cmd/web.ts
+++ b/packages/opencode/src/cli/cmd/web.ts
@@ -28,33 +28,45 @@ function getNetworkIPs() {
return results
}
+function normalizeBasePath(raw: string): string {
+ const input = raw?.trim()
+ if (!input) throw new Error("--base-path must not be empty")
+ const normalized = input.startsWith("/") ? input : `/${input}`
+ return normalized === "/" ? "/" : normalized.replace(/\/+$/, "")
+}
+
export const WebCommand = cmd({
command: "web",
- builder: (yargs) => withNetworkOptions(yargs),
+ builder: (yargs) =>
+ withNetworkOptions(yargs).option("base-path", {
+ type: "string",
+ default: "/",
+ description: "Base path to serve the app under (e.g. /opencode)",
+ }),
describe: "start opencode server and open web interface",
handler: async (args) => {
if (!Flag.OPENCODE_SERVER_PASSWORD) {
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
- const server = Server.listen(opts)
+ const base = normalizeBasePath(args["base-path"] as string)
+ const server = Server.listen({ ...opts, basePath: base })
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
+ const suffix = base === "/" ? "" : base
if (opts.hostname === "0.0.0.0") {
- // Show localhost for local access
- const localhostUrl = `http://localhost:${server.port}`
+ const localhostUrl = `http://localhost:${server.port}${suffix}`
UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl)
- // Show network IPs for remote access
const networkIPs = getNetworkIPs()
if (networkIPs.length > 0) {
for (const ip of networkIPs) {
UI.println(
UI.Style.TEXT_INFO_BOLD + " Network access: ",
UI.Style.TEXT_NORMAL,
- `http://${ip}:${server.port}`,
+ `http://${ip}:${server.port}${suffix}`,
)
}
}
@@ -63,14 +75,13 @@ export const WebCommand = cmd({
UI.println(
UI.Style.TEXT_INFO_BOLD + " mDNS: ",
UI.Style.TEXT_NORMAL,
- `${opts.mdnsDomain}:${server.port}`,
+ `${opts.mdnsDomain}:${server.port}${suffix}`,
)
}
- // Open localhost in browser
open(localhostUrl.toString()).catch(() => {})
} else {
- const displayUrl = server.url.toString()
+ const displayUrl = `${server.url.toString().replace(/\/$/, "")}${suffix}`
UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl)
open(displayUrl).catch(() => {})
}
diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts
index e03fc8a9f301..9a59ff80be44 100644
--- a/packages/opencode/src/file/index.ts
+++ b/packages/opencode/src/file/index.ts
@@ -408,6 +408,12 @@ export namespace File {
}
})
+ const fallback = {
+ dir: "",
+ at: 0,
+ dirs: [] as string[],
+ }
+
export function init() {
state()
}
@@ -638,7 +644,19 @@ export namespace File {
}
if (!query) {
if (kind === "file") return result.files.slice(0, limit)
- return sortHiddenLast(result.dirs.toSorted()).slice(0, limit)
+ const dirs = sortHiddenLast(result.dirs.toSorted())
+ if (dirs.length) return dirs.slice(0, limit)
+ if (fallback.dir === Instance.directory && Date.now() - fallback.at < 2000) {
+ return fallback.dirs.slice(0, limit)
+ }
+ const root = await list("")
+ const cached = root
+ .filter((item) => item.type === "directory")
+ .map((item) => (item.path.endsWith("/") ? item.path : `${item.path}/`))
+ fallback.dir = Instance.directory
+ fallback.at = Date.now()
+ fallback.dirs = cached
+ return cached.slice(0, limit)
}
const items =
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index 30929bd9268f..4ca1bd12000a 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -36,6 +36,8 @@ export namespace Flag {
export declare const OPENCODE_CLIENT: string
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
+ export const OPENCODE_WEB_APP_URL = process.env["OPENCODE_WEB_APP_URL"]
+ export const OPENCODE_WEB_APP_DIR = process.env["OPENCODE_WEB_APP_DIR"]
export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL")
// Experimental
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 14c2346a6117..16cf9de6b311 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -44,6 +44,7 @@ import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
import { MDNS } from "./mdns"
import { lazy } from "@/util/lazy"
+import * as path from "node:path"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -51,11 +52,96 @@ globalThis.AI_SDK_LOG_WARNINGS = false
export namespace Server {
const log = Log.create({ service: "server" })
+ const localFile = new Set([
+ "/index.html",
+ "/oc-theme-preload.js",
+ "/favicon.ico",
+ "/favicon.svg",
+ "/favicon-v3.ico",
+ "/favicon-v3.svg",
+ "/favicon-96x96.png",
+ "/favicon-96x96-v3.png",
+ "/apple-touch-icon.png",
+ "/apple-touch-icon-v3.png",
+ "/social-share.png",
+ "/social-share-zen.png",
+ "/site.webmanifest",
+ "/web-app-manifest-192x192.png",
+ "/web-app-manifest-512x512.png",
+ "/_headers",
+ ])
+
+ const rootCache = {
+ shown: false,
+ }
+
+ function normalizeBasePath(raw?: string) {
+ if (!raw) return ""
+ const input = raw.trim()
+ if (!input) throw new Error("basePath must not be empty")
+ const prefixed = input.startsWith("/") ? input : `/${input}`
+ return prefixed === "/" ? "" : prefixed.replace(/\/+$/, "")
+ }
+
+ function getUIURL() {
+ const raw = Flag.OPENCODE_WEB_APP_URL ?? "https://app.opencode.ai"
+ const value = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `https://${raw}`
+ try {
+ return new URL(value)
+ } catch {
+ throw new Error(`invalid OPENCODE_WEB_APP_URL: ${raw}`)
+ }
+ }
+
+ function strip(path: string, base: string) {
+ if (!base) return path
+ if (path === base) return "/"
+ if (path.startsWith(base + "/")) return path.slice(base.length)
+ return path
+ }
+
+ function rewrite(input: string, type: string, base: string, js: "assets" | "all" = "all") {
+ const root = `${base}/`
+ const fix = (text: string) =>
+ text.replaceAll(`${root}hub/assets/`, `${root}assets/`).replaceAll("/hub/assets/", `${root}assets/`)
+ if (type.includes("text/html")) {
+ return fix(
+ input
+ .replace(//gi, "")
+ .replace(/((?:src|href|content)=["'])\/(?!\/)/g, `$1${root}`)
+ .replace(/
/i, ``)
+ .replace(/\s*]*>\s*/g, "\n"),
+ )
+ }
+ if (type.includes("javascript")) {
+ const pattern =
+ js === "all"
+ ? /([=:(,\[]\s*["'])\/(?=(?:global|project|provider|session|find|file|path|event|assets|oc-theme-preload|favicon|apple-touch-icon|social-share))/g
+ : /([=:(,\[]\s*["'])\/(?=(?:assets|oc-theme-preload|favicon|apple-touch-icon|social-share))/g
+ return fix(input.replace(pattern, `$1${root}`))
+ }
+ if (type.includes("text/css")) {
+ return fix(input.replace(/url\((["']?)\/(?!\/)/g, `url($1${root}`))
+ }
+ return fix(input)
+ }
+
+ function history(base: string) {
+ return `;(function(){var b=${JSON.stringify(base)};globalThis.__OPENCODE_BASE_PATH=b;var f=function(u){if(typeof u!=="string")return u;if(!u.startsWith("/")||u.startsWith("//"))return u;if(u===b||u.startsWith(b+"/"))return u;return b+u};var p=history.pushState.bind(history);history.pushState=function(s,t,u){return p(s,t,f(u))};var r=history.replaceState.bind(history);history.replaceState=function(s,t,u){return r(s,t,f(u))}})();\n`
+ }
+
export const Default = lazy(() => createApp({}))
- export const createApp = (opts: { cors?: string[] }): Hono => {
- const app = new Hono()
- return app
+ export const createApp = (opts: { cors?: string[]; basePath?: string }): Hono => {
+ const base = normalizeBasePath(opts.basePath)
+ const ui = getUIURL()
+ const dir = Flag.OPENCODE_WEB_APP_DIR ? path.resolve(Flag.OPENCODE_WEB_APP_DIR) : undefined
+ if (dir && !rootCache.shown) {
+ rootCache.shown = true
+ log.warn("using local web app directory", { dir })
+ }
+ const inner = new Hono()
+ inner
.onError((err, c) => {
log.error("failed", {
error: err,
@@ -84,16 +170,17 @@ export namespace Server {
return basicAuth({ username, password })(c, next)
})
.use(async (c, next) => {
- const skipLogging = c.req.path === "/log"
+ const route = strip(c.req.path, base)
+ const skipLogging = route === "/log"
if (!skipLogging) {
log.info("request", {
method: c.req.method,
- path: c.req.path,
+ path: route,
})
}
const timer = log.time("request", {
method: c.req.method,
- path: c.req.path,
+ path: route,
})
await next()
if (!skipLogging) {
@@ -190,7 +277,7 @@ export namespace Server {
},
)
.use(async (c, next) => {
- if (c.req.path === "/log") return next()
+ if (strip(c.req.path, base) === "/log") return next()
const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = Filesystem.resolve(
@@ -219,7 +306,7 @@ export namespace Server {
.use(WorkspaceRouterMiddleware)
.get(
"/doc",
- openAPIRouteHandler(app, {
+ openAPIRouteHandler(inner, {
documentation: {
info: {
title: "opencode",
@@ -554,21 +641,71 @@ export namespace Server {
},
)
.all("/*", async (c) => {
- const path = c.req.path
+ const upstream = strip(c.req.path, base)
+ const target = new URL(upstream, ui)
- const response = await proxy(`https://app.opencode.ai${path}`, {
- ...c.req,
- headers: {
- ...c.req.raw.headers,
- host: "app.opencode.ai",
- },
- })
+ const response = dir
+ ? await (async () => {
+ const rel = upstream === "/" ? "/index.html" : upstream
+ const hidden = rel
+ .split("/")
+ .filter(Boolean)
+ .some((part) => part.startsWith("."))
+ if (hidden) return new Response("forbidden", { status: 403 })
+
+ const allowed = rel.startsWith("/assets/") || localFile.has(rel)
+ if (!allowed && path.extname(rel) !== "") return new Response("not found", { status: 404 })
+
+ const file = path.resolve(dir, "." + rel)
+ const inside = file === dir || file.startsWith(dir + path.sep)
+ if (!inside) return new Response("forbidden", { status: 403 })
+
+ const asset = Bun.file(file)
+ if (await asset.exists()) return new Response(asset)
+
+ const route = path.extname(rel) === ""
+ if (route) {
+ const index = Bun.file(path.join(dir, "index.html"))
+ if (await index.exists()) return new Response(index)
+ }
+
+ return new Response("not found", { status: 404 })
+ })()
+ : await proxy(target.toString(), {
+ ...c.req,
+ headers: {
+ ...c.req.raw.headers,
+ host: ui.host,
+ },
+ })
+ const type = response.headers.get("content-type") ?? ""
+ const text = type.includes("text/html") || type.includes("javascript") || type.includes("text/css")
+ if (base && text) {
+ const mode: "assets" | "all" = dir ? "assets" : "all"
+ let body = rewrite(await response.text(), type, base, mode)
+ if (upstream === "/oc-theme-preload.js") body = history(base) + body
+ const next = new Response(body, response)
+ next.headers.delete("content-length")
+ next.headers.delete("etag")
+ next.headers.delete("last-modified")
+ next.headers.set("cache-control", "no-store")
+ next.headers.set(
+ "Content-Security-Policy",
+ "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
+ )
+ return next
+ }
response.headers.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
)
return response
})
+
+ if (!base) return inner
+ const app = new Hono()
+ app.route(base, inner)
+ return app
}
export async function openapi() {
@@ -595,6 +732,7 @@ export namespace Server {
mdns?: boolean
mdnsDomain?: string
cors?: string[]
+ basePath?: string
}) {
url = new URL(`http://${opts.hostname}:${opts.port}`)
const app = createApp(opts)
diff --git a/packages/opencode/test/server/base-path.test.ts b/packages/opencode/test/server/base-path.test.ts
new file mode 100644
index 000000000000..cde0102cf541
--- /dev/null
+++ b/packages/opencode/test/server/base-path.test.ts
@@ -0,0 +1,156 @@
+import { describe, expect, test } from "bun:test"
+import { Server } from "../../src/server/server"
+import { Flag } from "../../src/flag/flag"
+
+const ui = (() => {
+ const raw = Flag.OPENCODE_WEB_APP_URL ?? "https://app.opencode.ai"
+ const value = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `https://${raw}`
+ const parsed = new URL(value)
+ return `${parsed.protocol}//${parsed.host}`
+})()
+
+describe("Server.createApp basePath", () => {
+ test("default (no basePath): API route accessible at root", async () => {
+ const app = Server.createApp({})
+ const res = await app.request("/event")
+ // /event exists (returns SSE 200); any non-404 means the route matched
+ expect(res.status).not.toBe(404)
+ })
+
+ test("with basePath: prefixed API route returns non-404", async () => {
+ const app = Server.createApp({ basePath: "/opencode" })
+ const res = await app.request("/opencode/event")
+ expect(res.status).not.toBe(404)
+ })
+
+ test("with basePath: unprefixed API route returns 404", async () => {
+ const app = Server.createApp({ basePath: "/opencode" })
+ const res = await app.request("/event")
+ expect(res.status).toBe(404)
+ })
+
+ test("with basePath: UI proxy at prefixed path strips prefix before forwarding", async () => {
+ const prev = globalThis.fetch
+ let call = ""
+ globalThis.fetch = (async (...args: Parameters): Promise => {
+ const req = args[0]
+ const url = typeof req === "string" ? req : req instanceof URL ? req.toString() : req.url
+ if (!url.startsWith(ui)) return prev(...args)
+ call = url
+ return new Response("ok", { status: 200, headers: { "content-type": "text/plain" } })
+ }) as typeof fetch
+
+ try {
+ const app = Server.createApp({ basePath: "/foo" })
+ const res = await app.request("/foo/index.html")
+ expect(res.status).toBe(200)
+ expect(call.endsWith("/index.html")).toBe(true)
+ } finally {
+ globalThis.fetch = prev
+ }
+ })
+
+ test("with basePath: UI proxy at unprefixed path returns 404", async () => {
+ const app = Server.createApp({ basePath: "/foo" })
+ const res = await app.request("/index.html")
+ expect(res.status).toBe(404)
+ })
+
+ test("default (no basePath): UI proxy catch-all works at root", async () => {
+ const prev = globalThis.fetch
+ globalThis.fetch = (async (...args: Parameters): Promise => {
+ const req = args[0]
+ const url = typeof req === "string" ? req : req instanceof URL ? req.toString() : req.url
+ if (!url.startsWith(ui)) return prev(...args)
+ return new Response("ok", { status: 200, headers: { "content-type": "text/plain" } })
+ }) as typeof fetch
+
+ try {
+ const app = Server.createApp({})
+ const res = await app.request("/index.html")
+ expect(res.status).toBe(200)
+ } finally {
+ globalThis.fetch = prev
+ }
+ })
+
+ test("with basePath: UI proxy rewrites absolute asset urls", async () => {
+ const prev = globalThis.fetch
+ const mock = async (...args: Parameters): Promise => {
+ const req = args[0]
+ const url = typeof req === "string" ? req : req instanceof URL ? req.toString() : req.url
+ if (!url.startsWith(ui)) return prev(...args)
+ return new Response(
+ ``,
+ {
+ status: 200,
+ headers: { "content-type": "text/html" },
+ },
+ )
+ }
+ globalThis.fetch = mock as typeof fetch
+
+ try {
+ const app = Server.createApp({ basePath: "/foo" })
+ const res = await app.request("/foo/")
+ const body = await res.text()
+ expect(res.status).toBe(200)
+ expect(body).toContain('href="/foo/assets/app.css"')
+ expect(body).toContain('src="/foo/assets/app.js"')
+ expect(body).toContain('content="/foo/social.png"')
+ expect(body).toContain('href="/foo/assets/hub.css"')
+ expect(body).not.toContain('rel="manifest"')
+ } finally {
+ globalThis.fetch = prev
+ }
+ })
+
+ test("with basePath: JS rewrite keeps absolute filesystem paths", async () => {
+ const prev = globalThis.fetch
+ const mock = async (...args: Parameters): Promise => {
+ const req = args[0]
+ const url = typeof req === "string" ? req : req instanceof URL ? req.toString() : req.url
+ if (!url.startsWith(ui)) return prev(...args)
+ return new Response('const a="/global/health"; const b="/home/jovyan";', {
+ status: 200,
+ headers: { "content-type": "application/javascript" },
+ })
+ }
+ globalThis.fetch = mock as typeof fetch
+
+ try {
+ const app = Server.createApp({ basePath: "/foo" })
+ const res = await app.request("/foo/assets/index.js")
+ const body = await res.text()
+ expect(body).toContain('"/foo/global/health"')
+ expect(body).toContain('"/home/jovyan"')
+ expect(body).not.toContain('"/foo/home/jovyan"')
+ } finally {
+ globalThis.fetch = prev
+ }
+ })
+
+ test("with basePath: oc-theme-preload patches history navigation", async () => {
+ const prev = globalThis.fetch
+ const mock = async (...args: Parameters): Promise => {
+ const req = args[0]
+ const url = typeof req === "string" ? req : req instanceof URL ? req.toString() : req.url
+ if (!url.startsWith(ui)) return prev(...args)
+ return new Response("console.log('theme')", {
+ status: 200,
+ headers: { "content-type": "text/javascript" },
+ })
+ }
+ globalThis.fetch = mock as typeof fetch
+
+ try {
+ const app = Server.createApp({ basePath: "/foo" })
+ const res = await app.request("/foo/oc-theme-preload.js")
+ const body = await res.text()
+ expect(body).toContain("history.pushState")
+ expect(body).toContain('var b="/foo"')
+ } finally {
+ globalThis.fetch = prev
+ }
+ })
+})
diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx
index 6b1c3dee57e2..c80f022835a9 100644
--- a/packages/web/src/content/docs/cli.mdx
+++ b/packages/web/src/content/docs/cli.mdx
@@ -460,12 +460,13 @@ This starts an HTTP server and opens a web browser to access OpenCode through a
#### Flags
-| Flag | Description |
-| ------------ | ------------------------------------------ |
-| `--port` | Port to listen on |
-| `--hostname` | Hostname to listen on |
-| `--mdns` | Enable mDNS discovery |
-| `--cors` | Additional browser origin(s) to allow CORS |
+| Flag | Description |
+| -------------- | ----------------------------------------------------- |
+| `--port` | Port to listen on |
+| `--hostname` | Hostname to listen on |
+| `--mdns` | Enable mDNS discovery |
+| `--cors` | Additional browser origin(s) to allow CORS |
+| `--base-path` | Base path to serve under for reverse-proxy sub-paths |
---
diff --git a/packages/web/src/content/docs/web.mdx b/packages/web/src/content/docs/web.mdx
index 52b97460c49f..0a54c74d34e1 100644
--- a/packages/web/src/content/docs/web.mdx
+++ b/packages/web/src/content/docs/web.mdx
@@ -70,6 +70,36 @@ You can customize the mDNS domain name to run multiple instances on the same net
opencode web --mdns --mdns-domain myproject.local
```
+### Base Path
+
+To serve OpenCode behind a reverse proxy at a sub-path, use `--base-path`:
+
+```bash
+opencode web --base-path /opencode
+```
+
+The server and browser URL will include the prefix:
+
+```
+ Web interface: http://localhost:4096/opencode
+```
+
+All API routes and the UI are served under the prefix. Requests to the unprefixed paths return 404.
+
+**Example nginx reverse-proxy config:**
+
+```nginx
+location /opencode/ {
+ proxy_pass http://127.0.0.1:4096/opencode/;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+}
+```
+
+---
+
### CORS
To allow additional domains for CORS (useful for custom frontends):