diff --git a/src/app/[locale]/admin/openclaw/page.tsx b/src/app/[locale]/admin/openclaw/page.tsx
new file mode 100644
index 0000000..55c1335
--- /dev/null
+++ b/src/app/[locale]/admin/openclaw/page.tsx
@@ -0,0 +1,71 @@
+import { redirect } from "next/navigation";
+import { getTranslations } from "next-intl/server";
+import { getSessionUser } from "@/lib/session";
+import { listTenants, getOpenClawDefaults } from "@/lib/k8s";
+import { OpenClawAdminPanel } from "@/components/admin/openclaw-admin-panel";
+
+/**
+ * /admin/openclaw — platform-default OpenClaw image + per-tenant
+ * overrides table.
+ *
+ * Two sections:
+ * 1. Default — readable from `pieced-openclaw-config` ConfigMap.
+ * Editable via the same form. Empty fields show as "(unset)"
+ * and the operator falls back to its built-in default in that
+ * case (intentionally invisible to the portal — the binary's
+ * baked version moves with releases and we don't want the UI
+ * to claim a misleading "current default").
+ * 2. Tenant table — every tenant in the cluster with its current
+ * override (or "follows default"). Clicking a row opens a small
+ * inline editor.
+ *
+ * Authorization is gated server-side: `user.isPlatform` only. Any
+ * other user gets redirected to /dashboard.
+ */
+export default async function OpenClawAdminPage() {
+ const user = await getSessionUser();
+ if (!user) redirect("/login");
+ if (!user.isPlatform) redirect("/dashboard");
+ const t = await getTranslations("openclawAdmin");
+
+ // Parallel fetch — defaults and tenants are independent.
+ const [defaults, tenants] = await Promise.all([
+ getOpenClawDefaults(),
+ listTenants(),
+ ]);
+
+ // Sort tenants: overridden first (more interesting to review),
+ // then alphabetically by display name. Helps the admin spot which
+ // tenants are off the platform default at a glance.
+ const sorted = [...tenants].sort((a, b) => {
+ const aOverride = a.spec.openClawImage ? 1 : 0;
+ const bOverride = b.spec.openClawImage ? 1 : 0;
+ if (aOverride !== bOverride) return bOverride - aOverride;
+ return (a.spec.displayName || a.metadata.name).localeCompare(
+ b.spec.displayName || b.metadata.name
+ );
+ });
+
+ return (
+
+
+ {/* Sub-tools: links to other admin pages. Plain links rather
+ than nav-shell entries — these are platform-team utilities,
+ not main navigation. */}
+
+ {t("openclawTool")}
+
diff --git a/src/app/api/admin/openclaw/route.ts b/src/app/api/admin/openclaw/route.ts
new file mode 100644
index 0000000..40fdbc4
--- /dev/null
+++ b/src/app/api/admin/openclaw/route.ts
@@ -0,0 +1,75 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { getSessionUser } from "@/lib/session";
+import { getOpenClawDefaults, setOpenClawDefaults } from "@/lib/k8s";
+import { safeError } from "@/lib/errors";
+
+/**
+ * Platform-wide default OpenClaw image tag (admin-only).
+ *
+ * GET — read the current default tag from the
+ * `pieced-openclaw-config` ConfigMap. Can be empty string if no
+ * default is configured; the operator uses its built-in fallback
+ * in that case.
+ *
+ * PATCH — update the tag. Send "" to clear. The operator watches
+ * this ConfigMap and re-enqueues all tenants without a per-tenant
+ * override on change, so existing tenants roll forward to the new
+ * default automatically. Tenants WITH an override are unaffected.
+ *
+ * Tag-only by design — see operator notes.
+ */
+
+const patchSchema = z.object({
+ defaultTag: z.string().trim().max(256),
+});
+
+export async function GET() {
+ const user = await getSessionUser();
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ if (!user.isPlatform) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ try {
+ return NextResponse.json(await getOpenClawDefaults());
+ } catch (e: any) {
+ console.error("Failed to read openclaw defaults:", e);
+ return NextResponse.json(
+ { error: safeError(e, "Failed to read defaults") },
+ { status: 500 }
+ );
+ }
+}
+
+export async function PATCH(req: NextRequest) {
+ const user = await getSessionUser();
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ if (!user.isPlatform) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ const body = await req.json().catch(() => null);
+ const parsed = patchSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Invalid input", details: parsed.error.flatten() },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const next = await setOpenClawDefaults({
+ defaultTag: parsed.data.defaultTag,
+ });
+ return NextResponse.json(next);
+ } catch (e: any) {
+ console.error("Failed to update openclaw defaults:", e);
+ return NextResponse.json(
+ { error: safeError(e, "Failed to update defaults") },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/admin/tenants/[name]/openclaw-image/route.ts b/src/app/api/admin/tenants/[name]/openclaw-image/route.ts
new file mode 100644
index 0000000..e7c973a
--- /dev/null
+++ b/src/app/api/admin/tenants/[name]/openclaw-image/route.ts
@@ -0,0 +1,78 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { getSessionUser } from "@/lib/session";
+import { getTenant, patchTenantSpec } from "@/lib/k8s";
+import { safeError } from "@/lib/errors";
+
+/**
+ * Per-tenant OpenClaw image override (admin-only).
+ *
+ * Why admin-only: customers cannot pick OpenClaw versions. This
+ * exists so the platform team can A/B-test new releases on specific
+ * tenants without rolling them out fleet-wide. The endpoint enforces
+ * `user.isPlatform`; even owners of the tenant's org cannot use it.
+ *
+ * PATCH body shapes:
+ * - { tag: "2026.4.22" } → use this tag
+ * - { tag: "" } or empty body → clear override (revert to platform
+ * default)
+ *
+ * Tag-only by design — see operator notes for rationale.
+ */
+
+const patchSchema = z.object({
+ tag: z.string().trim().max(256).optional(),
+});
+
+export async function PATCH(
+ req: NextRequest,
+ { params }: { params: Promise<{ name: string }> }
+) {
+ const user = await getSessionUser();
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ if (!user.isPlatform) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const { name } = await params;
+ const tenant = await getTenant(name);
+ if (!tenant) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ const body = await req.json().catch(() => null);
+ const parsed = patchSchema.safeParse(body ?? {});
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Invalid input", details: parsed.error.flatten() },
+ { status: 400 }
+ );
+ }
+
+ const tag = parsed.data.tag ?? "";
+ const isClearing = tag === "";
+
+ // Merge-patch semantics: openClawImage: null removes the field
+ // from the spec; openClawImage: { tag } sets it.
+ const spec: any = isClearing
+ ? { openClawImage: null }
+ : { openClawImage: { tag } };
+
+ try {
+ const updated = await patchTenantSpec(name, spec);
+ return NextResponse.json({
+ message: isClearing
+ ? "Override cleared; tenant follows platform default."
+ : "Override set.",
+ openClawImage: updated.spec.openClawImage ?? null,
+ });
+ } catch (e: any) {
+ console.error("Failed to set tenant openclaw image:", e);
+ return NextResponse.json(
+ { error: safeError(e, "Failed to update tenant image") },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/components/admin/openclaw-admin-panel.tsx b/src/components/admin/openclaw-admin-panel.tsx
new file mode 100644
index 0000000..4cbffe0
--- /dev/null
+++ b/src/components/admin/openclaw-admin-panel.tsx
@@ -0,0 +1,277 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { Card } from "@/components/ui/card";
+import type { OpenClawDefaults } from "@/lib/k8s";
+
+interface TenantRow {
+ name: string;
+ displayName: string;
+ phase: string;
+ override: { tag: string } | null;
+}
+
+interface Props {
+ initialDefaults: OpenClawDefaults;
+ tenants: TenantRow[];
+}
+
+/**
+ * Two-section admin UI:
+ * - Default editor card at the top — single input for the tag.
+ * - Tenant table below — each row has an inline edit/clear control.
+ *
+ * No optimistic updates: every save round-trips to the API and we
+ * router.refresh() to re-render the server-side state. Keeps the UI
+ * honest about what's actually applied (controller-runtime watch
+ * latency can be a couple of seconds).
+ *
+ * Tag-only by design — see operator notes for rationale.
+ */
+export function OpenClawAdminPanel({ initialDefaults, tenants }: Props) {
+ const t = useTranslations("openclawAdmin");
+ const tCommon = useTranslations("common");
+ const router = useRouter();
+
+ const [defaults, setDefaults] = useState(initialDefaults);
+ const [defaultTag, setDefaultTag] = useState(initialDefaults.defaultTag);
+ const [savingDefault, setSavingDefault] = useState(false);
+ const [defaultError, setDefaultError] = useState("");
+ const [defaultSaved, setDefaultSaved] = useState(false);
+
+ const onSaveDefault = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setSavingDefault(true);
+ setDefaultError("");
+ setDefaultSaved(false);
+ try {
+ const res = await fetch("/api/admin/openclaw", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ defaultTag: defaultTag.trim() }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || t("saveFailed"));
+ }
+ const next = await res.json();
+ setDefaults(next);
+ setDefaultSaved(true);
+ } catch (e: any) {
+ setDefaultError(e.message);
+ } finally {
+ setSavingDefault(false);
+ }
+ };
+
+ return (
+