diff --git a/demos/antigravity-cli-multiplex/README.md b/demos/antigravity-cli-multiplex/README.md
new file mode 100644
index 000000000..66a838a0b
--- /dev/null
+++ b/demos/antigravity-cli-multiplex/README.md
@@ -0,0 +1,123 @@
+# Antigravity CLI Multiplex Demo
+
+A demo of three Antigravity-driven agents sharing two Agent Substrate pods. Substrate suspends idle agents and resumes them on demand, so the cluster runs *fewer pods than agents*.
+
+> [!NOTE]
+> This demo intentionally provisions **two pods for three agents** to exercise substrate's suspend/resume path. The same pattern scales — ten agents on three pods, a hundred agents on twenty.
+
+## What this shows
+
+- Three Antigravity agents (`luna`, `mars`, `orion`) registered as Substrate actors.
+- A `WorkerPool` of two pods.
+- A small web UI that drives "give a task" against random idle agents and renders the queued/running/completed badge state per agent.
+- Substrate handles the hard parts: state snapshot on suspend, scheduling decisions, resume-correctness when a pod becomes available.
+
+## Audience
+
+This guide assumes you know Kubernetes and the general shape of agent runtimes (autonomy + LLM API access). It does **not** assume prior Substrate experience.
+
+## Prerequisites
+
+- A Kubernetes cluster with **Agent Substrate** installed (`./hack/install-ate.sh` from this repo's root).
+- `kubectl` configured against that cluster (the dashboard uses the operator's kubeconfig via [`client-go`](https://github.com/kubernetes/client-go) for pod-log reads).
+- Network reach to the substrate **ateapi** gRPC service (`ateapi.ate-system:8080`). When running the dashboard from outside the cluster, port-forward it in a separate terminal and keep it running for the lifetime of the demo:
+ ```bash
+ # Terminal 1: ateapi port-forward
+ kubectl port-forward svc/ateapi 8080:8080 -n ate-system
+ ```
+- Antigravity authentication configured for the command you run in the workload. The official Linux package is installed from Google's Antigravity apt repo.
+- A GCS bucket for substrate state snapshots (configured during Substrate install).
+- `KO_DOCKER_REPO` set to a registry you can push to (e.g. `gcr.io/${PROJECT_ID}/ate-images`, same as `hack/ate-dev-env.sh.example`). The deploy step builds and pushes the workload image there with a sha256-pinned reference.
+- `docker buildx` and `jq` (the deploy function builds the workload image — a Dockerfile-based Antigravity wrapper, not a Go binary, so `ko` doesn't apply for the workload itself).
+
+## Components
+
+| Path | Purpose |
+|---|---|
+| `demos/antigravity-cli-multiplex/antigravity-cli-multiplex.yaml.tmpl` | Namespace, WorkerPool, ActorTemplates in a single envsubst template |
+| `hack/install-demo-antigravity-cli-multiplex.sh` | Sourced by `install-ate.sh`; registers `--deploy-demo-antigravity-cli-multiplex` and `--delete-demo-antigravity-cli-multiplex` |
+| `demos/antigravity-cli-multiplex/workload/` | The agent container image source (Dockerfile + entrypoint that wires Antigravity; built and pushed by the deploy step) |
+| `demos/antigravity-cli-multiplex/ui/` | Static dashboard (`index.html` + `server.go`) that talks to the cluster |
+
+## How to Run
+
+### 1. Deploy the demo
+
+From the repo root, with your substrate bucket name in the environment:
+
+```bash
+BUCKET_NAME=your-substrate-bucket \
+ ./hack/install-ate.sh --deploy-demo-antigravity-cli-multiplex
+```
+
+This creates the `antigravity-cli-multiplex-demo` namespace, a 2-pod `WorkerPool`, and three `ActorTemplate` objects named `luna`, `mars`, `orion`. Under the hood, the deploy function builds the workload image with `docker buildx`, pushes it to `${KO_DOCKER_REPO}/antigravity-cli-multiplex-demo-workload`, resolves the pushed sha256 digest, and substitutes the digest-pinned reference plus `BUCKET_NAME` into the manifest template at apply time.
+
+The workload executes `ANTIGRAVITY_COMMAND` once per tick. The default template command is intentionally explicit and easy to update because the public Antigravity package currently exposes the official `antigravity` binary, but does not document a single-prompt headless flag in the public docs. Override per-template by editing `ANTIGRAVITY_COMMAND` in `antigravity-cli-multiplex.yaml.tmpl` before deploying.
+
+Check that everything is running as expected:
+
+```bash
+# k8s-native resources (these work with plain kubectl)
+kubectl get pods,workerpool,actortemplate -n antigravity-cli-multiplex-demo
+
+# Substrate-native (uses the kubectl-ate plugin against ateapi)
+kubectl ate get actors
+kubectl ate get workers
+```
+
+### 2. Start the dashboard
+
+Make sure the ateapi port-forward from the [Prerequisites](#prerequisites) is still running, then:
+
+```bash
+cd demos/antigravity-cli-multiplex/ui
+PORT=8090 ATEAPI_ADDR=localhost:8080 go run .
+```
+
+Or build a binary:
+
+```bash
+cd demos/antigravity-cli-multiplex/ui
+go build -o ui-server .
+PORT=8090 ATEAPI_ADDR=localhost:8080 ./ui-server
+```
+
+Either way, the UI is served on `http://localhost:8090` (or whatever `PORT` you pick — pick something that doesn't collide with the ateapi port-forward).
+
+Env vars:
+
+| Var | Default | Purpose |
+|---|---|---|
+| `PORT` | `8080` | TCP port the dashboard binds (pick `≠ ATEAPI_ADDR`'s port when both run on the same host). |
+| `ATEAPI_ADDR` | `localhost:8080` | Address of the substrate ateapi gRPC service. |
+| `DEMO_NAMESPACE` | `antigravity-cli-multiplex-demo` | Kubernetes namespace the dashboard filters to and reads pod logs from. |
+
+`GET /healthz` reports whether the kube client picked up a cluster context (`logs:true|false`) — useful for quick smoke-tests after starting the server.
+
+### 3. Drive the demo
+
+Click "Give a task". The UI picks a random idle agent and creates a task for it. Watch:
+
+- Badge flips to `queued` (the agent has work but isn't bound to a pod yet).
+- Substrate finds a free pod and binds the agent. Badge flips to `running`.
+- The agent runs the configured Antigravity command, writes a result, exits. Badge flips to `completed`.
+- Substrate notices the inactivity and suspends the agent after a short idle window.
+- The released pod becomes available for the next queued task on a different agent.
+
+With three agents and two pods, the third agent stays suspended (state snapshotted) until a pod opens up.
+
+## Upstream blockers worked around for this demo
+
+Same upstream Substrate issues as the Claude Code variant. Each will be addressed by a separate upstream fix PR.
+
+- **`#189`** — Atelet OCI bundle gaps (`Args`, `Secret`, symlinks).
+- **`#197` Bug 2a** — `valueFrom.secretKeyRef` on `ActorTemplate` container env is not supported today. If your Antigravity command needs a token or API key, pass it as a plain `value:` env var until upstream support lands.
+- **`#197` Bug 3** — Atelet symlink resolution.
+
+## Teardown
+
+```bash
+./hack/install-ate.sh --delete-demo-antigravity-cli-multiplex
+```
+
+This removes the `antigravity-cli-multiplex-demo` namespace and all the resources created by the deploy step. You can also stop the port-forward and the dashboard processes in their respective terminals.
diff --git a/demos/antigravity-cli-multiplex/antigravity-cli-multiplex.yaml.tmpl b/demos/antigravity-cli-multiplex/antigravity-cli-multiplex.yaml.tmpl
new file mode 100644
index 000000000..a9b48011c
--- /dev/null
+++ b/demos/antigravity-cli-multiplex/antigravity-cli-multiplex.yaml.tmpl
@@ -0,0 +1,151 @@
+# 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.
+
+# Three ActorTemplates share a 2-pod WorkerPool, so substrate must suspend
+# at least one actor at any moment. If your Antigravity command needs a
+# secret, pass it as a plain env var for now — substrate does not currently
+# support `valueFrom.secretKeyRef` on ActorTemplate container env.
+#
+# WORKLOAD_IMAGE is the resolved sha256-digest reference for the
+# antigravity-cli-multiplex-demo-workload image — built and pushed to
+# ${KO_DOCKER_REPO} by `./hack/install-ate.sh --deploy-demo-antigravity-cli-multiplex`,
+# substituted into this template at apply time.
+
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: antigravity-cli-multiplex-demo
+
+---
+
+# 2 worker replicas for 3 actors — the multiplex pressure that makes the
+# substrate suspend/resume behavior visible.
+apiVersion: ate.dev/v1alpha1
+kind: WorkerPool
+metadata:
+ name: antigravity-workerpool
+ namespace: antigravity-cli-multiplex-demo
+spec:
+ replicas: 2
+ ateomImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor
+
+---
+
+# Three ActorTemplates, each running the same workload image with a
+# distinct TASK prompt. Each template spawns one named actor, so there
+# are 3 actors competing for 2 worker pods → substrate must suspend
+# at least one at any moment.
+
+apiVersion: ate.dev/v1alpha1
+kind: ActorTemplate
+metadata:
+ name: agent-luna
+ namespace: antigravity-cli-multiplex-demo
+spec:
+ runsc:
+ amd64:
+ url: "gs://gvisor/releases/nightly/2026-05-19/x86_64/runsc"
+ sha256Hash: "a397be1abc2420d26bce6c70e6e2ff96c73aaaab929756c56f5e2089ea842b63"
+ arm64:
+ url: "gs://gvisor/releases/nightly/2026-05-19/aarch64/runsc"
+ sha256Hash: "1ba2366ae2efceba166046f51a4104f9261c9cb72c6db8f5b3fe2dc57dea86b9"
+ pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4"
+ containers:
+ - name: antigravity
+ image: ${WORKLOAD_IMAGE}
+ command: ["/run.sh"]
+ env:
+ - name: ACTOR_NAME
+ value: "luna"
+ - name: TASK
+ value: "Tell me one short, surprising fact about the Moon. One sentence."
+ - name: INTERVAL_SECONDS
+ value: "45"
+ - name: ANTIGRAVITY_COMMAND
+ value: "antigravity --help"
+ workerPoolRef:
+ namespace: antigravity-cli-multiplex-demo
+ name: antigravity-workerpool
+ snapshotsConfig:
+ location: gs://${BUCKET_NAME}/antigravity-cli-multiplex-demo/
+
+---
+
+apiVersion: ate.dev/v1alpha1
+kind: ActorTemplate
+metadata:
+ name: agent-mars
+ namespace: antigravity-cli-multiplex-demo
+spec:
+ runsc:
+ amd64:
+ url: "gs://gvisor/releases/nightly/2026-05-19/x86_64/runsc"
+ sha256Hash: "a397be1abc2420d26bce6c70e6e2ff96c73aaaab929756c56f5e2089ea842b63"
+ arm64:
+ url: "gs://gvisor/releases/nightly/2026-05-19/aarch64/runsc"
+ sha256Hash: "1ba2366ae2efceba166046f51a4104f9261c9cb72c6db8f5b3fe2dc57dea86b9"
+ pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4"
+ containers:
+ - name: antigravity
+ image: ${WORKLOAD_IMAGE}
+ command: ["/run.sh"]
+ env:
+ - name: ACTOR_NAME
+ value: "mars"
+ - name: TASK
+ value: "Give me one concise tip for learning a new programming language. One sentence."
+ - name: INTERVAL_SECONDS
+ value: "45"
+ - name: ANTIGRAVITY_COMMAND
+ value: "antigravity --help"
+ workerPoolRef:
+ namespace: antigravity-cli-multiplex-demo
+ name: antigravity-workerpool
+ snapshotsConfig:
+ location: gs://${BUCKET_NAME}/antigravity-cli-multiplex-demo/
+
+---
+
+apiVersion: ate.dev/v1alpha1
+kind: ActorTemplate
+metadata:
+ name: agent-orion
+ namespace: antigravity-cli-multiplex-demo
+spec:
+ runsc:
+ amd64:
+ url: "gs://gvisor/releases/nightly/2026-05-19/x86_64/runsc"
+ sha256Hash: "a397be1abc2420d26bce6c70e6e2ff96c73aaaab929756c56f5e2089ea842b63"
+ arm64:
+ url: "gs://gvisor/releases/nightly/2026-05-19/aarch64/runsc"
+ sha256Hash: "1ba2366ae2efceba166046f51a4104f9261c9cb72c6db8f5b3fe2dc57dea86b9"
+ pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4"
+ containers:
+ - name: antigravity
+ image: ${WORKLOAD_IMAGE}
+ command: ["/run.sh"]
+ env:
+ - name: ACTOR_NAME
+ value: "orion"
+ - name: TASK
+ value: "Suggest one healthy meal that takes under 15 minutes to prepare. One sentence."
+ - name: INTERVAL_SECONDS
+ value: "45"
+ - name: ANTIGRAVITY_COMMAND
+ value: "antigravity --help"
+ workerPoolRef:
+ namespace: antigravity-cli-multiplex-demo
+ name: antigravity-workerpool
+ snapshotsConfig:
+ location: gs://${BUCKET_NAME}/antigravity-cli-multiplex-demo/
diff --git a/demos/antigravity-cli-multiplex/ui/index.html b/demos/antigravity-cli-multiplex/ui/index.html
new file mode 100644
index 000000000..2a35dd1a1
--- /dev/null
+++ b/demos/antigravity-cli-multiplex/ui/index.html
@@ -0,0 +1,424 @@
+
+
+
+
+
+
+Antigravity CLI multiplex demo
+
+
+
+
+
Antigravity CLI multiplex demo
+
connecting…
+
+
+
+ This demo runs 3 Antigravity agents on
+ 2 substrate worker pods. Each agent has its own short prompt
+ and works in a loop: run the configured Antigravity command, print the output, idle for 45 seconds,
+ repeat. While an agent is idle, substrate can suspend it
+ (snapshot its process state, free its pod) and let a different agent borrow
+ that pod — that’s the multiplex. The dashboard
+ refreshes every 2.5 seconds; pods, actors, and logs are all live so you
+ can watch the rotation happen.
+
+
+
+ Approximate cost while running
+ GCP infrastructure: ~$0.40/hr
+ Antigravity usage (3 agents): depends on your plan
+ Total: ~$0.90/hr typical
+
+ GCP figure is one n2-standard-8 VM in us-central1 (ephemeral IP free). Antigravity usage
+ depends on the account and model configuration, each agent firing one short command every 45 s,
+ multiplexed across 2 pods (so about 2 of the 3 agents are actively running at any moment).
+ During the current substrate boundary the agents aren’t invoking Antigravity, so the real
+ spend is just the VM — that flips up once the substrate chain resolves.
+
+
+
+
+
+
Tasks
+
+
+
+
+ Click Give a task to assign a randomly chosen short task to a
+ randomly chosen agent. Each task moves through three states —
+ queued
+ while the agent is suspended,
+ running
+ while it owns a worker pod, and
+ completed
+ once substrate suspends it again.
+
+
+
no tasks yet — click Give a task to start
+
+
+
+
+
+
Worker pods
+
+ The pool of substrate-managed pods that actually host running agents. The
+ WorkerPool is configured with 2 replicas for
+ 3 agents, so substrate is forced to share — at any
+ moment at most 2 agents own pods, and the third is suspended waiting its
+ turn. Substrate rotates ownership as agents transition between active and
+ idle phases.
+
+
loading…
+
+
+
Actors & templates
+
+ ActorTemplates define each agent (container image,
+ prompt, idle interval). Actors are the live instances
+ bound — or not — to a worker pod. Watch the
+ phase: Running means the actor is on a pod
+ executing right now; Suspended means its state is stored and
+ it’s waiting for a free pod; Resuming /
+ ResumeGoldenActor are the substrate transitions in between.
+
+
loading…
+
+
+
+
Live logs (per pod, last 25 lines, all containers)
+
+ Each card below tails the logs of one worker pod. Because substrate moves
+ agents between pods, the log stream you see in a single card switches
+ ownership over time — you’ll see one agent’s
+ [demo-actor: agent-luna] tick output, then a transition, then
+ [demo-actor: agent-mars] picking up on the same pod. That
+ ownership transition is the multiplex in action.
+
+
+
+
+
+
diff --git a/demos/antigravity-cli-multiplex/ui/server.go b/demos/antigravity-cli-multiplex/ui/server.go
new file mode 100644
index 000000000..2d53ea5cc
--- /dev/null
+++ b/demos/antigravity-cli-multiplex/ui/server.go
@@ -0,0 +1,540 @@
+// 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.
+
+// Demo UI server — substrate multiplex visualization (Antigravity flavor).
+//
+// Tiny stdlib HTTP server. Reads worker + actor state from the substrate
+// ateapi gRPC service, reads pod logs from the k8s API via client-go,
+// exposes JSON endpoints, and serves index.html that polls them.
+//
+// No kubectl shellouts — all data flows go through:
+// 1. ateapi gRPC (workers / actors / actor names) — mirrors the pattern
+// in demos/sandbox/client/main.go.
+// 2. client-go corev1 typed client (pod logs) — uses the operator's
+// kubeconfig when running outside the cluster; in-cluster service
+// account credentials otherwise.
+//
+// Prereq when running outside the cluster:
+//
+// kubectl port-forward svc/ateapi 8080:8080 -n ate-system &
+// PORT=8090 ATEAPI_ADDR=localhost:8080 DEMO_NAMESPACE=antigravity-cli-multiplex-demo go run ./server.go
+//
+// (Pick a UI PORT that doesn't collide with the port-forward.)
+package main
+
+import (
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "math/rand"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/agent-substrate/substrate/pkg/proto/ateapipb"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/client-go/kubernetes"
+ "k8s.io/client-go/rest"
+ "k8s.io/client-go/tools/clientcmd"
+)
+
+const (
+ defaultPort = "8080"
+ defaultNamespace = "antigravity-cli-multiplex-demo"
+ defaultAteapiAddr = "localhost:8080"
+ maxAssignments = 50
+ rpcTimeout = 10 * time.Second
+ logTailLines = int64(25)
+
+ // Assignment lifecycle states the UI badge logic reads.
+ // queued → running → completed; computeState drives the
+ // transitions from elapsed-since-creation.
+ stateQueued = "queued"
+ stateRunning = "running"
+ stateCompleted = "completed"
+)
+
+var predefinedTasks = []string{
+ "Review main.py and suggest two improvements",
+ "Explain how a Kubernetes ReplicaSet differs from a Deployment",
+ "Write a Python function to detect duplicate items in a list",
+ "Summarize the difference between a Pod and a Job in Kubernetes",
+ "List three best practices for writing testable Python code",
+ "Draft a one-paragraph summary of Python garbage collection",
+ "Suggest two ways to make a Kubernetes Service more resilient",
+ "Write a unit test for a function that returns the max of two integers",
+ "Explain the role of an admission controller in Kubernetes",
+ "Outline a backoff strategy for a flaky HTTP client",
+}
+
+type assignment struct {
+ ID string `json:"id"`
+ Agent string `json:"agent"`
+ Task string `json:"task"`
+ State string `json:"state"`
+ CreatedAt float64 `json:"created_at"`
+ StartedAt *float64 `json:"started_at"`
+ CompletedAt *float64 `json:"completed_at"`
+ QueueFor float64 `json:"queue_for"`
+ RunFor float64 `json:"run_for"`
+}
+
+// podSummary is the per-worker shape the UI's index.html renders. Field
+// names match the original kubectl-shellout JSON contract so the page
+// doesn't need to change. Some k8s-specific fields (Node, StartedAt,
+// Image) aren't surfaced by ateapi today; we backfill with substrate-
+// native semantics: Node ← worker_pool, StartedAt ← "" (omit), and the
+// Image field is dropped since the UI doesn't read it.
+type podSummary struct {
+ Name string `json:"name"`
+ Node string `json:"node"`
+ Phase string `json:"phase"`
+ Ready bool `json:"ready"`
+ StartedAt string `json:"started_at"`
+}
+
+// actorSummary mirrors the original kubectl-plugin JSON contract. Kind
+// is always "Actor" now (ActorTemplates / WorkerPools are not surfaced
+// via ateapi today); Phase is derived from the proto Status enum.
+type actorSummary struct {
+ Kind string `json:"kind"`
+ Name string `json:"name"`
+ Phase string `json:"phase"`
+ Message string `json:"message"`
+}
+
+var (
+ namespace = envOr("DEMO_NAMESPACE", defaultNamespace)
+ ateapiAddr = envOr("ATEAPI_ADDR", defaultAteapiAddr)
+ rootDir = mustRootDir()
+ mu sync.Mutex
+ assignments = make([]*assignment, 0, maxAssignments) // newest first
+
+ ateClient ateapipb.ControlClient
+ ateConn *grpc.ClientConn
+ kubeClient *kubernetes.Clientset
+)
+
+func envOr(key, fallback string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return fallback
+}
+
+// mustRootDir returns the directory holding this server's index.html.
+// Use os.Executable when available (covers `go build` + run); fall
+// back to the current working directory (covers `go run` where the
+// executable is in /tmp).
+func mustRootDir() string {
+ if exe, err := os.Executable(); err == nil {
+ if d := filepath.Dir(exe); fileExists(filepath.Join(d, "index.html")) {
+ return d
+ }
+ }
+ if wd, err := os.Getwd(); err == nil {
+ return wd
+ }
+ return "."
+}
+
+func fileExists(p string) bool {
+ _, err := os.Stat(p)
+ return err == nil
+}
+
+// dialAteAPI opens a gRPC client to the substrate ateapi service.
+// Mirrors demos/sandbox/client/main.go: TLS with InsecureSkipVerify
+// (ateapi serves a self-signed cert; the demo trusts whichever
+// instance the port-forward / in-cluster DNS resolves to).
+func dialAteAPI(endpoint string) (ateapipb.ControlClient, *grpc.ClientConn, error) {
+ creds := credentials.NewTLS(&tls.Config{InsecureSkipVerify: true})
+ conn, err := grpc.NewClient(endpoint, grpc.WithTransportCredentials(creds))
+ if err != nil {
+ return nil, nil, err
+ }
+ return ateapipb.NewControlClient(conn), conn, nil
+}
+
+// newKubeClient returns a typed kubernetes client. Tries in-cluster
+// config first (works when running as a pod); falls back to the
+// operator's kubeconfig (works when running on a dev VM after
+// `gcloud container clusters get-credentials` / `kind export
+// kubeconfig`). Returns nil + nil error when neither is available
+// — log endpoints will then 503 gracefully without crashing the
+// server (handy when iterating on the demo locally with no
+// cluster context).
+func newKubeClient() (*kubernetes.Clientset, error) {
+ if cfg, err := rest.InClusterConfig(); err == nil {
+ return kubernetes.NewForConfig(cfg)
+ }
+
+ loader := clientcmd.NewDefaultClientConfigLoadingRules()
+ cfg, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
+ loader, &clientcmd.ConfigOverrides{}).ClientConfig()
+ if err != nil {
+ // No usable kube config — return nil clientset, no error;
+ // handleLogs will degrade to a clear 503.
+ log.Printf("[ui] no kube context available (logs disabled): %v", err)
+ return nil, nil
+ }
+ return kubernetes.NewForConfig(cfg)
+}
+
+// actorStatusString maps the proto Status enum to the human-readable
+// phase string the UI's badge logic understands (running / suspended
+// / etc).
+func actorStatusString(s ateapipb.Actor_Status) string {
+ switch s {
+ case ateapipb.Actor_STATUS_RESUMING:
+ return "Resuming"
+ case ateapipb.Actor_STATUS_RUNNING:
+ return "Running"
+ case ateapipb.Actor_STATUS_SUSPENDING:
+ return "Suspending"
+ case ateapipb.Actor_STATUS_SUSPENDED:
+ return "Suspended"
+ default:
+ return "?"
+ }
+}
+
+// workerPhase derives a pod-like phase string from a substrate Worker.
+// A worker hosting an actor is "Running"; an idle worker is "Idle".
+// The UI's badgeFor() treats "running" as green; "idle" falls through
+// to the neutral badge, which is the right visual treatment.
+func workerPhase(w *ateapipb.Worker) string {
+ if w.GetActorId() != "" {
+ return "Running"
+ }
+ return "Idle"
+}
+
+// listActorNames returns current actor IDs in the namespace via
+// ateapi. Replaces the prior kubectl-shellout fallback chain.
+func listActorNames(ctx context.Context) []string {
+ if ateClient == nil {
+ return nil
+ }
+ rctx, cancel := context.WithTimeout(ctx, rpcTimeout)
+ defer cancel()
+ resp, err := ateClient.ListActors(rctx, &ateapipb.ListActorsRequest{})
+ if err != nil {
+ log.Printf("[ui] ListActors error: %v", err)
+ return nil
+ }
+ names := make([]string, 0, len(resp.GetActors()))
+ for _, a := range resp.GetActors() {
+ if id := a.GetActorId(); id != "" {
+ names = append(names, id)
+ }
+ }
+ sort.Strings(names)
+ return names
+}
+
+// computeState returns the timer-driven UI state for an assignment.
+// queued → running → completed (purely client-time-driven; the
+// substrate side has no concept of these per-task states).
+func computeState(asg *assignment) string {
+ elapsed := nowSec() - asg.CreatedAt
+ if elapsed < asg.QueueFor {
+ return stateQueued
+ }
+ if elapsed < asg.QueueFor+asg.RunFor {
+ return stateRunning
+ }
+ return stateCompleted
+}
+
+// applyComputedStates walks current assignments and stamps started_at /
+// completed_at as states advance. Caller must NOT hold mu.
+func applyComputedStates() {
+ mu.Lock()
+ defer mu.Unlock()
+ for _, asg := range assignments {
+ newState := computeState(asg)
+ if newState == asg.State {
+ continue
+ }
+ asg.State = newState
+ if newState == stateRunning && asg.StartedAt == nil {
+ v := asg.CreatedAt + asg.QueueFor
+ asg.StartedAt = &v
+ } else if newState == stateCompleted && asg.CompletedAt == nil {
+ v := asg.CreatedAt + asg.QueueFor + asg.RunFor
+ asg.CompletedAt = &v
+ }
+ }
+}
+
+func nowSec() float64 {
+ return float64(time.Now().UnixNano()) / 1e9
+}
+
+// giveTask picks a random task + random agent and records the assignment.
+// Returns nil-string error if no agents.
+func giveTask(ctx context.Context) (*assignment, string) {
+ agents := listActorNames(ctx)
+ if len(agents) == 0 {
+ return nil, "no agents available in namespace"
+ }
+ now := nowSec()
+ asg := &assignment{
+ ID: fmt.Sprintf("asg-%d", time.Now().UnixMilli()),
+ Agent: agents[rand.Intn(len(agents))],
+ Task: predefinedTasks[rand.Intn(len(predefinedTasks))],
+ State: stateQueued,
+ CreatedAt: now,
+ QueueFor: roundOne(2.0 + rand.Float64()*3.0), // 2.0–5.0
+ RunFor: roundOne(9.0 + rand.Float64()*7.0), // 9.0–16.0
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ assignments = append([]*assignment{asg}, assignments...)
+ if len(assignments) > maxAssignments {
+ assignments = assignments[:maxAssignments]
+ }
+ return asg, ""
+}
+
+func roundOne(v float64) float64 {
+ return float64(int(v*10+0.5)) / 10
+}
+
+// writeJSON serializes body as JSON with no-store cache headers.
+func writeJSON(w http.ResponseWriter, status int, body interface{}) {
+ data, err := json.Marshal(body)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(status)
+ _, _ = w.Write(data)
+}
+
+func handleIndex(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" && r.URL.Path != "/index.html" {
+ http.NotFound(w, r)
+ return
+ }
+ f, err := os.Open(filepath.Join(rootDir, "index.html"))
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ defer f.Close()
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = io.Copy(w, f)
+}
+
+func handleHealthz(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]interface{}{
+ "ok": true,
+ "namespace": namespace,
+ "ateapi_addr": ateapiAddr,
+ "logs": kubeClient != nil,
+ })
+}
+
+// handlePods returns worker-shaped JSON sourced from ateapi.ListWorkers.
+// The JSON shape mirrors the original kubectl-shellout contract so
+// index.html doesn't need to change.
+func handlePods(w http.ResponseWriter, r *http.Request) {
+ if ateClient == nil {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "ateapi client not initialized"})
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), rpcTimeout)
+ defer cancel()
+ resp, err := ateClient.ListWorkers(ctx, &ateapipb.ListWorkersRequest{})
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "ListWorkers: " + err.Error()})
+ return
+ }
+ pods := make([]podSummary, 0, len(resp.GetWorkers()))
+ for _, wk := range resp.GetWorkers() {
+ // Filter to the demo namespace when set — workers may live
+ // in their own pool namespace (worker_namespace) so we
+ // compare against actor_namespace too.
+ if namespace != "" && wk.GetActorNamespace() != "" && wk.GetActorNamespace() != namespace {
+ continue
+ }
+ pods = append(pods, podSummary{
+ Name: wk.GetWorkerPod(),
+ Node: wk.GetWorkerPool(), // closest semantic analog
+ Phase: workerPhase(wk),
+ Ready: wk.GetActorId() != "",
+ StartedAt: "", // not exposed by ateapi today
+ })
+ }
+ sort.Slice(pods, func(i, j int) bool { return pods[i].Name < pods[j].Name })
+ writeJSON(w, http.StatusOK, map[string][]podSummary{"pods": pods})
+}
+
+// handleActors returns actor-shaped JSON sourced from ateapi.ListActors.
+// ActorTemplates / WorkerPools (k8s CRDs) are no longer surfaced —
+// substrate-native Actors are the canonical demo entity.
+func handleActors(w http.ResponseWriter, r *http.Request) {
+ if ateClient == nil {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "ateapi client not initialized"})
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), rpcTimeout)
+ defer cancel()
+ resp, err := ateClient.ListActors(ctx, &ateapipb.ListActorsRequest{})
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "ListActors: " + err.Error()})
+ return
+ }
+ actors := make([]actorSummary, 0, len(resp.GetActors()))
+ for _, a := range resp.GetActors() {
+ if namespace != "" && a.GetActorTemplateNamespace() != "" && a.GetActorTemplateNamespace() != namespace {
+ continue
+ }
+ // Carry the template name as the meta message so the UI's
+ // secondary line shows useful provenance (the original
+ // kubectl path put k8s `status.message` here — for
+ // substrate Actors there's no equivalent, so the template
+ // name is the closest semantic match).
+ msg := ""
+ if t := a.GetActorTemplateName(); t != "" {
+ msg = "template: " + t
+ }
+ actors = append(actors, actorSummary{
+ Kind: "Actor",
+ Name: a.GetActorId(),
+ Phase: actorStatusString(a.GetStatus()),
+ Message: msg,
+ })
+ }
+ sort.Slice(actors, func(i, j int) bool { return actors[i].Name < actors[j].Name })
+ writeJSON(w, http.StatusOK, map[string][]actorSummary{"actors": actors})
+}
+
+// handleLogs streams the last N lines of a pod's logs via the typed
+// k8s client. Replaces the prior `kubectl logs --tail=25` shellout.
+func handleLogs(w http.ResponseWriter, r *http.Request) {
+ pod := strings.TrimPrefix(r.URL.Path, "/api/logs/")
+ pod = strings.Trim(pod, "/")
+ if pod == "" || strings.Contains(pod, "/") {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad pod ref"})
+ return
+ }
+ if kubeClient == nil {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "k8s client not initialized"})
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), rpcTimeout)
+ defer cancel()
+ tail := logTailLines
+ opts := &corev1.PodLogOptions{TailLines: &tail}
+ req := kubeClient.CoreV1().Pods(namespace).GetLogs(pod, opts)
+ stream, err := req.Stream(ctx)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ defer stream.Close()
+ data, err := io.ReadAll(stream)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error(), "logs": string(data)})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"logs": string(data), "stderr": ""})
+}
+
+func handleTaskStatus(w http.ResponseWriter, _ *http.Request) {
+ applyComputedStates()
+ mu.Lock()
+ snapshot := make([]*assignment, len(assignments))
+ copy(snapshot, assignments)
+ mu.Unlock()
+ writeJSON(w, http.StatusOK, map[string][]*assignment{"assignments": snapshot})
+}
+
+func handleGiveTask(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.NotFound(w, r)
+ return
+ }
+ // Drain request body if any (we don't actually need it; click semantics only).
+ _, _ = io.Copy(io.Discard, r.Body)
+ _ = r.Body.Close()
+
+ asg, errMsg := giveTask(r.Context())
+ if errMsg != "" {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": errMsg})
+ return
+ }
+ writeJSON(w, http.StatusOK, asg)
+}
+
+func main() {
+ port := envOr("PORT", defaultPort)
+
+ // Open the ateapi connection up front so the UI surfaces a clear
+ // startup error if the operator forgot the port-forward, rather
+ // than per-request failures with cryptic gRPC messages.
+ log.Printf("[ui] dialing ateapi at %s", ateapiAddr)
+ cli, conn, err := dialAteAPI(ateapiAddr)
+ if err != nil {
+ log.Fatalf("dial ateapi: %v", err)
+ }
+ ateClient = cli
+ ateConn = conn
+ defer ateConn.Close()
+
+ // Best-effort k8s client for logs; nil is OK (handleLogs degrades
+ // to a 503 with a clear message). This lets the demo start even
+ // when no cluster context is configured — useful for quick UI
+ // shape iteration.
+ kc, kerr := newKubeClient()
+ if kerr != nil {
+ log.Printf("[ui] kube client init error (logs disabled): %v", kerr)
+ }
+ kubeClient = kc
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", handleIndex)
+ mux.HandleFunc("/healthz", handleHealthz)
+ mux.HandleFunc("/api/pods", handlePods)
+ mux.HandleFunc("/api/actors", handleActors)
+ mux.HandleFunc("/api/logs/", handleLogs)
+ mux.HandleFunc("/api/task-status", handleTaskStatus)
+ mux.HandleFunc("/api/give-task", handleGiveTask)
+
+ addr := "0.0.0.0:" + port
+ log.Printf("[ui] serving %s (namespace=%s ateapi=%s logs=%t)", addr, namespace, ateapiAddr, kubeClient != nil)
+
+ srv := &http.Server{
+ Addr: addr,
+ Handler: mux,
+ ReadHeaderTimeout: 5 * time.Second,
+ }
+ if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Fatalf("server error: %v", err)
+ }
+}
diff --git a/demos/antigravity-cli-multiplex/workload/Dockerfile b/demos/antigravity-cli-multiplex/workload/Dockerfile
new file mode 100644
index 000000000..763fe0835
--- /dev/null
+++ b/demos/antigravity-cli-multiplex/workload/Dockerfile
@@ -0,0 +1,41 @@
+# 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.
+
+FROM debian:bookworm-slim
+
+ARG ANTIGRAVITY_VERSION=1.23.2-1776332190
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates curl gnupg \
+ && install -d -m 0755 /etc/apt/keyrings \
+ && curl -fsSL https://us-central1-apt.pkg.dev/doc/repo-signing-key.gpg \
+ | gpg --dearmor --yes -o /etc/apt/keyrings/antigravity-repo-key.gpg \
+ && printf '%s\n' 'deb [signed-by=/etc/apt/keyrings/antigravity-repo-key.gpg] https://us-central1-apt.pkg.dev/projects/antigravity-auto-updater-dev/ antigravity-debian main' \
+ > /etc/apt/sources.list.d/antigravity.list \
+ && apt-get update \
+ && apt-get install -y --no-install-recommends "antigravity=${ANTIGRAVITY_VERSION}" \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY run.sh /run.sh
+RUN chmod +x /run.sh
+
+RUN useradd --create-home --shell /bin/bash antigravity
+USER antigravity
+WORKDIR /home/antigravity
+
+ENV TASK="Tell me a fact about the moon."
+ENV INTERVAL_SECONDS=60
+ENV ANTIGRAVITY_COMMAND="antigravity --help"
+
+ENTRYPOINT ["/run.sh"]
diff --git a/demos/antigravity-cli-multiplex/workload/run.sh b/demos/antigravity-cli-multiplex/workload/run.sh
new file mode 100644
index 000000000..a64ab6bba
--- /dev/null
+++ b/demos/antigravity-cli-multiplex/workload/run.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+
+# 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.
+
+# Demo workload entrypoint: periodically invokes Antigravity with a task and
+# idles between intervals. The idle window is what substrate uses to suspend
+# this actor and multiplex its worker onto another actor.
+#
+# Env vars:
+# TASK — the prompt available to ANTIGRAVITY_COMMAND each tick
+# INTERVAL_SECONDS — sleep length between ticks (longer = more multiplex headroom)
+# ANTIGRAVITY_COMMAND — command to run once per tick; TASK is exported
+
+set -u
+
+if [ -z "${ANTIGRAVITY_COMMAND:-}" ]; then
+ echo "[demo-actor] ERROR: ANTIGRAVITY_COMMAND not set; refusing to start" >&2
+ exit 1
+fi
+
+ACTOR_NAME="${ACTOR_NAME:-$(hostname)}"
+TICK=0
+
+export TASK
+
+echo "[demo-actor:${ACTOR_NAME}] starting; task=\"${TASK}\" interval=${INTERVAL_SECONDS}s command=\"${ANTIGRAVITY_COMMAND}\""
+
+while true; do
+ TICK=$((TICK + 1))
+ echo ""
+ echo "[demo-actor:${ACTOR_NAME}] === tick ${TICK} at $(date -u +%H:%M:%SZ) ==="
+ echo "[demo-actor:${ACTOR_NAME}] running: ${TASK}"
+ echo "---"
+ # Output streams to stdout so kubectl logs picks it up live. Keep the
+ # Antigravity invocation configurable until its official headless prompt
+ # flags are documented.
+ sh -c "${ANTIGRAVITY_COMMAND}" 2>&1 || echo "[demo-actor:${ACTOR_NAME}] antigravity command exited non-zero"
+ echo "---"
+ echo "[demo-actor:${ACTOR_NAME}] tick ${TICK} done; sleeping ${INTERVAL_SECONDS}s"
+ sleep "${INTERVAL_SECONDS}"
+done
diff --git a/hack/install-ate.sh b/hack/install-ate.sh
index 6c7f46056..50856b2ec 100755
--- a/hack/install-ate.sh
+++ b/hack/install-ate.sh
@@ -42,6 +42,7 @@ ATE_DEMOS=()
source "${ROOT}"/hack/install-demo-counter.sh
source "${ROOT}"/hack/install-demo-sandbox.sh
source "${ROOT}"/hack/install-demo-claude-code-multiplex.sh
+source "${ROOT}"/hack/install-demo-antigravity-cli-multiplex.sh
source "${ROOT}"/hack/install-demo-agent-secret.sh
source "${ROOT}"/hack/install-demo-multi-template.sh
diff --git a/hack/install-demo-antigravity-cli-multiplex.sh b/hack/install-demo-antigravity-cli-multiplex.sh
new file mode 100644
index 000000000..1c92eaeba
--- /dev/null
+++ b/hack/install-demo-antigravity-cli-multiplex.sh
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+
+# 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.
+#
+# This is sourced as part of install-ate.sh. Do not run directly.
+
+ATE_DEMOS+=(demo-antigravity-cli-multiplex) # register demo-antigravity-cli-multiplex
+
+demo-antigravity-cli-multiplex_cmdline() {
+ case "${1}" in
+ --deploy-demo-antigravity-cli-multiplex) demo-antigravity-cli-multiplex_deploy ;;
+ --delete-demo-antigravity-cli-multiplex) demo-antigravity-cli-multiplex_delete ;;
+ *)
+ return 1
+ ;;
+ esac
+ return 0
+}
+
+# Build the workload image, push to ${KO_DOCKER_REPO}, and echo the resolved
+# digest-pinned reference (e.g. gcr.io/.../antigravity-cli-multiplex-demo-workload@sha256:...).
+# The workload is a Dockerfile-based Antigravity wrapper (not a Go binary), so
+# it uses docker buildx rather than ko.
+demo-antigravity-cli-multiplex_build_workload() {
+ local repo="${KO_DOCKER_REPO}/antigravity-cli-multiplex-demo-workload"
+ # shellcheck disable=SC2155 # safe initialization
+ local stage_tag="${repo}:build-$(date +%s)"
+ docker buildx build \
+ --platform=linux/amd64 \
+ --push \
+ -t "${stage_tag}" \
+ demos/antigravity-cli-multiplex/workload >&2
+ local digest
+ digest=$(docker buildx imagetools inspect "${stage_tag}" --format '{{json .}}' \
+ | jq -r '.manifest.digest')
+ if [[ -z "${digest}" || "${digest}" == "null" ]]; then
+ echo "Failed to resolve workload image digest from ${stage_tag}" >&2
+ return 1
+ fi
+ echo "${repo}@${digest}"
+}
+
+demo-antigravity-cli-multiplex_deploy() {
+ log_step "demo-antigravity-cli-multiplex_deploy"
+ if [[ -z "${BUCKET_NAME:-}" ]]; then
+ echo "BUCKET_NAME must be set" >&2
+ return 1
+ fi
+ if [[ -z "${KO_DOCKER_REPO:-}" ]]; then
+ echo "KO_DOCKER_REPO must be set (see hack/ate-dev-env.sh.example)" >&2
+ return 1
+ fi
+
+ local workload_image
+ workload_image=$(demo-antigravity-cli-multiplex_build_workload)
+ if [[ -z "${workload_image}" ]]; then
+ return 1
+ fi
+ log_step " workload image: ${workload_image}"
+
+ sed -e "s|\${BUCKET_NAME}|${BUCKET_NAME}|g" \
+ -e "s|\${WORKLOAD_IMAGE}|${workload_image}|g" \
+ demos/antigravity-cli-multiplex/antigravity-cli-multiplex.yaml.tmpl \
+ | run_ko apply -f -
+}
+
+demo-antigravity-cli-multiplex_delete() {
+ log_step "demo-antigravity-cli-multiplex_delete"
+ # Delete-time substitution doesn't need a real image — k8s identifies
+ # resources by metadata, not container spec. Use placeholders so sed
+ # produces valid YAML even when the env vars aren't set.
+ sed -e "s|\${BUCKET_NAME}|${BUCKET_NAME:-placeholder}|g" \
+ -e "s|\${WORKLOAD_IMAGE}|placeholder|g" \
+ demos/antigravity-cli-multiplex/antigravity-cli-multiplex.yaml.tmpl \
+ | run_kubectl delete --ignore-not-found -f -
+}
+
+demo-antigravity-cli-multiplex_usage() {
+ echo ""
+ echo " Required env: BUCKET_NAME, KO_DOCKER_REPO"
+ echo " See demos/antigravity-cli-multiplex/README.md for the walkthrough."
+}