From 6ee3ba024d6a477d35dfcf13a63b38e51ef9b7b8 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 27 Aug 2026 17:49:23 -0700 Subject: [PATCH 01/15] Point the chart's own examples at Intelligence hosts that exist Every values file under `ci/` named `wss://gateway.cloud.copilotkit.ai`, which does not resolve, and `https://api.cloud.copilotkit.ai`, which resolves to something answering 404. A target file is meant to be the shortest honest starting point for a cluster, so copying one produced a deployment whose realtime plane pointed at a host that has never existed. `.env.example` has carried the working pair the whole time. --- charts/openbot/ci/aks-values.yaml | 4 ++-- charts/openbot/ci/eks-sandbox-values.yaml | 4 ++-- charts/openbot/ci/eks-values.yaml | 4 ++-- charts/openbot/ci/gke-values.yaml | 4 ++-- charts/openbot/ci/self-hosted-values.yaml | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/charts/openbot/ci/aks-values.yaml b/charts/openbot/ci/aks-values.yaml index de50c308..1058f8c3 100644 --- a/charts/openbot/ci/aks-values.yaml +++ b/charts/openbot/ci/aks-values.yaml @@ -2,8 +2,8 @@ config: initialAdminEmails: admin@example.com intelligence: - apiUrl: https://api.cloud.copilotkit.ai - gatewayWsUrl: wss://gateway.cloud.copilotkit.ai + apiUrl: https://api.intelligence.copilotkit.ai + gatewayWsUrl: wss://realtime.intelligence.copilotkit.ai auth: google: clientId: example.apps.googleusercontent.com diff --git a/charts/openbot/ci/eks-sandbox-values.yaml b/charts/openbot/ci/eks-sandbox-values.yaml index 2a7335a1..95d860a7 100644 --- a/charts/openbot/ci/eks-sandbox-values.yaml +++ b/charts/openbot/ci/eks-sandbox-values.yaml @@ -20,8 +20,8 @@ config: initialAdminEmails: admin@example.com intelligence: - apiUrl: https://api.cloud.copilotkit.ai - gatewayWsUrl: wss://gateway.cloud.copilotkit.ai + apiUrl: https://api.intelligence.copilotkit.ai + gatewayWsUrl: wss://realtime.intelligence.copilotkit.ai auth: google: clientId: example.apps.googleusercontent.com diff --git a/charts/openbot/ci/eks-values.yaml b/charts/openbot/ci/eks-values.yaml index 737a3c60..460caaa9 100644 --- a/charts/openbot/ci/eks-values.yaml +++ b/charts/openbot/ci/eks-values.yaml @@ -18,8 +18,8 @@ config: initialAdminEmails: admin@example.com intelligence: - apiUrl: https://api.cloud.copilotkit.ai - gatewayWsUrl: wss://gateway.cloud.copilotkit.ai + apiUrl: https://api.intelligence.copilotkit.ai + gatewayWsUrl: wss://realtime.intelligence.copilotkit.ai auth: google: clientId: example.apps.googleusercontent.com diff --git a/charts/openbot/ci/gke-values.yaml b/charts/openbot/ci/gke-values.yaml index b2531e0e..701e9141 100644 --- a/charts/openbot/ci/gke-values.yaml +++ b/charts/openbot/ci/gke-values.yaml @@ -2,8 +2,8 @@ config: initialAdminEmails: admin@example.com intelligence: - apiUrl: https://api.cloud.copilotkit.ai - gatewayWsUrl: wss://gateway.cloud.copilotkit.ai + apiUrl: https://api.intelligence.copilotkit.ai + gatewayWsUrl: wss://realtime.intelligence.copilotkit.ai auth: google: clientId: example.apps.googleusercontent.com diff --git a/charts/openbot/ci/self-hosted-values.yaml b/charts/openbot/ci/self-hosted-values.yaml index 688d4570..228dc70e 100644 --- a/charts/openbot/ci/self-hosted-values.yaml +++ b/charts/openbot/ci/self-hosted-values.yaml @@ -6,8 +6,8 @@ config: initialAdminEmails: admin@example.com intelligence: - apiUrl: https://api.cloud.copilotkit.ai - gatewayWsUrl: wss://gateway.cloud.copilotkit.ai + apiUrl: https://api.intelligence.copilotkit.ai + gatewayWsUrl: wss://realtime.intelligence.copilotkit.ai auth: google: clientId: example.apps.googleusercontent.com From 22dfde132a4537e60a694f1c28b7e2daa6c3796d Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 27 Aug 2026 17:49:24 -0700 Subject: [PATCH 02/15] Assert the CronJob fallbacks the same way the env-var ones are asserted `--reuse-values` leaves a key this release added simply missing, so every template fallback is reached on exactly the deployments nobody is watching. Two were checked against values.yaml and two were not, because the check could only read a rendered `name:`/`value:` pair and the routines schedule and the culler's deadline are fields on a CronJob. The routines schedule had already been wrong once, by a factor of five, which is the argument for asserting it rather than trusting the comment beside it. Read off the rendered workload, found by its component label rather than its name, so the check is not pinned to a release name and a suffix. --- scripts/check-new-values-keys.ts | 127 ++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 4 deletions(-) diff --git a/scripts/check-new-values-keys.ts b/scripts/check-new-values-keys.ts index 678db3f3..c64429c6 100644 --- a/scripts/check-new-values-keys.ts +++ b/scripts/check-new-values-keys.ts @@ -15,7 +15,7 @@ * * bun scripts/check-new-values-keys.ts [--since v0.0.4] */ -import { parse } from "yaml"; +import { parse, parseAllDocuments } from "yaml"; const [valuesFile, ...rest] = process.argv.slice(2); if (!valuesFile) { @@ -246,9 +246,10 @@ function renderedValue(out: string, variable: string): string | undefined { * runs in a job with no Helm — and a test that shells out to a binary which is not there returns * undefined rather than failing. */ -const chartValues = parse( - await Bun.file("charts/openbot/values.yaml").text(), -) as { config?: { handoff?: Record } }; +const rawChartValues = await Bun.file("charts/openbot/values.yaml").text(); +const chartValues = parse(rawChartValues) as { + config?: { handoff?: Record }; +}; const absent = render(["--set", "config.handoff=null"]); for (const { path, variable } of offSwitches) { const leaf = path.slice(path.lastIndexOf(".") + 1); @@ -263,6 +264,124 @@ for (const { path, variable } of offSwitches) { console.log(`${variable} falls back to ${got}, as values.yaml says`); } } +/* + * The same assertion for the fallbacks that are not env vars. + * + * `offSwitches` above reads a rendered `name:`/`value:` pair, so it can only see a fallback that + * reaches a container's environment. Two do not: the routines schedule and the culler's deadline are + * fields on a CronJob. They were left unchecked as "no drift test yet" and the routines schedule had + * already been wrong once — `* * * * *`, five times more often than anything documents — which is + * the whole argument for asserting it rather than trusting the two comments that describe it. + * + * Nulling the LEAF, not the parent, because that is the shape `--reuse-values` actually produces + * here: an existing release carries `routines.enabled` from the release that introduced it and + * simply has no `schedule` key, so nulling the parent would delete the feature and render nothing + * to assert against. + */ +/* + * The credential that lets `routines.enabled` render, in whichever place this target keeps secrets. + */ +const targetValues = parse(await Bun.file(valuesFile).text()) as { + externalSecrets?: { enabled?: boolean; data?: unknown[] }; +}; +function enableFor(component: string): string[] { + if (component === "culler") return ["--set", "computers.mode=sandbox"]; + const on = ["--set", "routines.enabled=true"]; + if (!targetValues.externalSecrets?.enabled) { + return [ + ...on, + "--set-string", + "secrets.workerSharedSecret=for-rendering-only", + ]; + } + const next = targetValues.externalSecrets.data?.length ?? 0; + return [ + ...on, + "--set", + `externalSecrets.data[${next}].secretKey=worker-shared-secret`, + "--set", + `externalSecrets.data[${next}].remoteRef.key=openbot/worker-shared-secret`, + ]; +} + +/** One step of a dotted path through parsed YAML, without asserting a shape it may not have. */ +function at(value: unknown, key: string): unknown { + return value !== null && typeof value === "object" + ? (value as Record)[key] + : undefined; +} + +/** What sits at a dotted path, or undefined if any step of it is missing. */ +function valueAt(root: unknown, path: readonly string[]): unknown { + return path.reduce((here, key) => at(here, key), root); +} + +const fieldFallbacks: ReadonlyArray<{ + /** The values key, as `--set` names it. */ + path: string; + /** The component label on the workload that carries the field. */ + component: string; + /** Where the field sits on the rendered resource. */ + field: readonly string[]; +}> = [ + { + path: "routines.schedule", + component: "routines", + field: ["spec", "schedule"], + }, + { + path: "computers.sandbox.culler.activeDeadlineSeconds", + component: "culler", + field: ["spec", "jobTemplate", "spec", "activeDeadlineSeconds"], + }, +]; + +const chartValuesTree = parse(rawChartValues) as unknown; + +for (const { path, component, field } of fieldFallbacks) { + const documented = valueAt(chartValuesTree, path.split(".")); + const attempt = render([...enableFor(component), "--set", `${path}=null`]); + if (!attempt.ok) { + console.error( + `::error::The chart failed to render with ${path} absent: ${attempt.err.trim().split("\n")[0]}`, + ); + bad += 1; + continue; + } + /* + * Found by its component label rather than by name, because a name is the release name plus a + * suffix and this check would then be pinned to both. + */ + const carriers = parseAllDocuments(attempt.out) + .map((document) => document.toJS() as unknown) + .filter( + (resource) => + valueAt(resource, [ + "metadata", + "labels", + "app.kubernetes.io/component", + ]) === component, + ); + if (carriers.length !== 1) { + console.error( + `::error::Rendering with ${path} absent produced ${carriers.length} workloads labelled ${component}, not one.`, + ); + bad += 1; + continue; + } + const got = valueAt(carriers[0], field); + if (String(got) !== String(documented)) { + console.error( + `::error::With ${path} absent, the ${component} workload rendered ${JSON.stringify(got)} but values.yaml documents ${JSON.stringify(documented)}.`, + ); + bad += 1; + continue; + } + console.log( + `${path} falls back to ${JSON.stringify(got)}, as values.yaml says`, + ); +} + for (const { path, variable } of offSwitches) { const attempt = render(["--set", `${path}=0`]); if (!attempt.ok) { From da88d08c7831c753d375cde53be5777257268a8a Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 27 Aug 2026 17:49:36 -0700 Subject: [PATCH 03/15] Make the Helm chart reachable from the docs, and say what a cluster needs before it `docs/README.md` indexed eight pages and none of them was Kubernetes. `charts/openbot/README.md` was linked from nowhere a reader would look, and the deployment page's AWS note offered ECS Express Mode and plain Fargate and stopped, so the shape that gives a Bot a computer of its own and runs the routines schedule was invisible to anyone following the docs. The chart README began at `helm upgrade --install` and assumed three things it does not create. Two were described further down; the third was written down nowhere: the published image carries `linux/amd64` only, so an arm64 node group cannot pull it and says `ImagePullBackOff` rather than anything about architecture. The Secret holding the database URL is now shown being made, because the key name the chart reads it by was stated only in a comment. The image measured 5.3 GB when the container work landed and measures 1.4 GB now. The reason given for it was right and is kept: 595 MB is still Firefox and WebKit, which nothing here launches. `ci/` has held five targets since the sandbox one arrived and the README counted four. --- charts/openbot/README.md | 41 ++++++++++++++++++++++++++++++++++++++-- docs/README.md | 1 + docs/deployment.md | 12 +++++++++--- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/charts/openbot/README.md b/charts/openbot/README.md index 9b8f99aa..5605b675 100644 --- a/charts/openbot/README.md +++ b/charts/openbot/README.md @@ -1,8 +1,44 @@ # OpenBot on Kubernetes -Runs OpenBot on any Kubernetes cluster: EKS, GKE, AKS, or your own. One chart, four targets, and the +Runs OpenBot on any Kubernetes cluster: EKS, GKE, AKS, or your own. One chart, five targets, and the only difference between them is values. +## What a cluster needs first + +Three things this chart assumes and does not create. + +**An image the cluster can pull.** A release publishes `ghcr.io/copilotkit/openbot:vX.Y.Z` +publicly, and that tag is what `image.tag` wants. It is built for **`linux/amd64` only**, so an +arm64 node group — Graviton on EKS, Tau T2A on GKE, Ampere on AKS — cannot run it: the pods sit in +`ImagePullBackOff` and the event says `no match for platform`, which reads like a broken registry +rather than a node pool of the wrong shape. Either run amd64 nodes, or build the image for the +architecture you have and push it somewhere the cluster can reach. Check before assuming: + +```sh +docker manifest inspect ghcr.io/copilotkit/openbot:v0.0.4 | grep architecture +``` + +**A default StorageClass**, or a named one. Both a Bot's computer and the bundled database ask for +a volume, and a fresh cluster often has no class marked default — see +[Check for a default StorageClass first](#check-for-a-default-storageclass-first), which is the +single most common reason a first install comes up with a pod stuck `Pending` and nothing saying +why. + +**A database, and the Secret that names it**, unless you are using the bundled one. The chart reads +a URL out of a Secret you make; it never writes your database credentials into a values file: + +```sh +kubectl create namespace openbot +kubectl -n openbot create secret generic openbot-database \ + --from-literal=database-url='postgresql://USER:PASSWORD@HOST:5432/openbot?sslmode=require' +``` + +Then `--set database.existingSecret=openbot-database`. The key must be `database-url`, or name a +different one with `database.existingSecretKey`. See +[Your own database](#your-own-database-which-is-what-a-real-deployment-uses) for `sslmode` and the +`vector` extension, both of which a managed database will otherwise fail on in a way that names the +wrong problem. + ## Install The bundled database and one administrator, which is the shortest thing that works: @@ -60,7 +96,7 @@ user and not the actual problem. creates it and a later one drops it again. On a managed database, create it once as the administrative role; `CREATE EXTENSION IF NOT EXISTS` then passes for an ordinary user. -## The four targets +## The five targets `ci/` holds a values file per target, and each is the shortest thing that expresses what is different about that cluster: @@ -69,6 +105,7 @@ about that cluster: | --- | --- | | `self-hosted-values.yaml` | Nothing turned on. If this file needs to grow, a default is wrong. | | `eks-values.yaml` | IRSA, Secrets Manager, ALB, zone spread, autoscaling. | +| `eks-sandbox-values.yaml` | The same, with a computer each rather than one shared browser. `shared` and `sandbox` render a different Deployment, different RBAC and a different pod template, so a target that renders only one checks half the chart. | | `gke-values.yaml` | Workload Identity, Secret Manager, Gateway API instead of an Ingress. | | `aks-values.yaml` | Workload identity, Key Vault, the AKS web app routing class. | diff --git a/docs/README.md b/docs/README.md index 9ef2cf6e..6b7c6668 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ Start with the root [README](../README.md), then use these references: - [Google Drive](plugins/google-drive.md) - [Notion](plugins/notion.md) - [Deployment](deployment.md): the container, what is in the image, minimum sizes, and the platform notes. +- [Kubernetes](../charts/openbot/README.md): the Helm chart, what a cluster needs before it, and the values that differ per cloud. - [Releasing](releasing.md): how a release is proposed, reviewed and published. Do not include credential values, customer data, transcripts, or local-only notes in public docs. diff --git a/docs/deployment.md b/docs/deployment.md index da5b23ee..827731a9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -55,7 +55,7 @@ Measured on the real image, one Bot, arm64. | --- | --- | --- | --- | | Memory | 409 MB idle, 498 MB after three page loads, 548 MB after a snapshot | **2 GB** | **4 GB** | | vCPU | 3 to 6 percent at rest, bursty while a page renders | **1** | **2** | -| Disk | 5.3 GB image | **8 GB** | 10 GB with room for `/workspace` | +| Disk | 1.4 GB image | **4 GB** | 8 GB with room for `/workspace` | **Why 2 GB when it measures at 550 MB.** That figure is one Bot with one page open. Every additional concurrent page is roughly another 100 to 200 MB, and Playwright's own guidance is to allow about @@ -132,6 +132,12 @@ in ECR, and is what AWS points App Runner users at now that App Runner takes no Plain ECS on Fargate behind an ALB is the answer if you want task definitions and fine-grained IAM. No shared-memory configuration is needed or possible. +**Kubernetes.** Everything above describes one container run by hand. A cluster is the other shape, +and it is the only one that gives a Bot a computer of its own, runs the routines schedule without +something outside the container, and scales the API past a single replica. That is the Helm chart: +[charts/openbot/README.md](../charts/openbot/README.md), which covers EKS, GKE, AKS and a plain +self-hosted cluster from the same templates. + **Azure Container Apps.** Managed ingress with TLS and custom domains. Note the **240-second request timeout**: the live screen holds a long connection, so expect it to reconnect. Concurrent WebSockets are capped at 350 per instance on the basic tier. @@ -141,8 +147,8 @@ which makes them the shortest path from nothing to a running deployment. ## Known costs -**The image is 5.3 GB**, most of it the Playwright base, which ships Firefox and WebKit alongside the -Chromium we use. Deleting them afterwards does not help, because the bytes still ship in the layer +**The image is 1.4 GB**, and 595 MB of that is Firefox and WebKit, which the Playwright base ships +alongside the Chromium we use and nothing here ever launches. Deleting them afterwards does not help, because the bytes still ship in the layer below. Building Chromium-only onto a slim base would cut this substantially and is not done yet. **A strict content-security-policy needs a hash or a nonce.** `app/index.html` runs a small inline From 50308fec471877e7a6e1106bf82c0a7dc2161fd4 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 27 Aug 2026 17:49:36 -0700 Subject: [PATCH 04/15] Say what the release is named for, and how a Bot is actually granted the right to address another The changelog's `Unreleased` section covered routines, screenshots, unread dots and thirty smaller things, and said nothing about one Bot handing work to another. It clears the bar the file sets for itself: two new environment variables, two new chart values, a grant kind that did not exist, and a capability that stays off until an administrator turns it on. Turning it on is the part that was wrong. The configuration reference said the grant "is made per Bot like any other grant", and every other grant is made on a screen at `/admin/plugins`. This one is not: `bot` is a kind the store and the API both accept, it has no catalogue entry, nothing in the app renders it, and `listForAgent` filters to `mcp` and `skill` so the read path drops it too. The call that does work is written out instead, naming which of the two Bots is which, because the pair is directional and guessing wrong grants the opposite handoff. --- CHANGELOG.md | 25 +++++++++++++++++++++++++ docs/configuration.md | 17 ++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3400b3e7..c35ba6f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,31 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### One Bot can hand work to another, and reach a person when no Bot will do + +A Bot asked something it is not the right Bot for can now put the question to one that is. The +addressed Bot answers as itself, with its own tools and its own knowledge, and the answer arrives in +the conversation the person was already in rather than somewhere they would have to go and look. A +Bot that judges no other Bot will do can instead reach the person who asked it. + +**No Bot may address any other until an administrator says so.** Which Bot may reach which is an +ordinary grant, made per Bot, and a Bot with no grant is told it cannot rather than quietly trying. +A Bot addressed by a name two Bots answer to is refused and both are named, because picking one +would be a guess about which colleague a person meant. + +Two ceilings, because a Bot deciding to ask another Bot is a Bot deciding to spend a run: +`BOT_HANDOFF_MAX_DEPTH` is how many Bots deep a chain may go and defaults to `1`, and **`0` switches +the capability off entirely** — the tool is not offered rather than offered and refused. +`BOT_HANDOFF_MAX_PER_RUN` is how many Bots one run may address and defaults to `3`. The Helm chart +takes the same two as `config.handoff.maxDepth` and `config.handoff.maxPerRun`. + +A hop that fails is reported back by the Bot that asked, after its attempts are spent, rather than +leaving the person watching a conversation that never finishes. One rough edge to know about: a hop +that is retried leaves one "asked" line per attempt in the addressed Bot's own transcript, so a hop +that took three attempts reads there as having been asked three times. + +No new tables: this uses the work queue that already fires the culler. + ### The framework Bot answers on 5.6-tier models, and can be told how hard to think Pointing `BOT_MODEL` at a `gpt-5.6-*` model gave a Bot that started, reported healthy, and then said diff --git a/docs/configuration.md b/docs/configuration.md index 1593ca5d..6980db95 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -204,7 +204,22 @@ Both refuse rather than truncate, and both are refused at start-up if they are n zero or more: a deployment that typed `two` and silently got the default would believe it had set a cap. -Which Bots may address which is a grant, not a variable. It is made per Bot like any other grant. +Which Bots may address which is a grant, not a variable, and no Bot may address any other until one +is made. **It has no screen yet.** Every other grant is made at `/admin/plugins`; this one is made +against the API, by an administrator, naming the Bot doing the addressing and the Bot being +addressed: + +```sh +# Let `general-assistant` hand work to `knowledge`. +curl -X POST "$OPENBOT_URL/api/plugins/grants" \ + -H 'content-type: application/json' \ + --cookie "$SESSION" \ + -d '{"kind":"bot","ref":"knowledge","agentId":"general-assistant"}' +``` + +`ref` is the Bot that may be reached and `agentId` is the Bot doing the reaching, so the pair is +directional: granting the reverse is a second call. `DELETE` the same three as query parameters +takes it away. A Bot may not be granted itself, and only an administrator may grant at all. ## Computer and supervisor From 784f140a4754fa6c029d98feaeed32e6b1607e97 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 27 Aug 2026 17:54:29 -0700 Subject: [PATCH 05/15] Write down the cluster the chart was proven on The prerequisites above say what a cluster needs; this is the shortest thing that produces one, because "create an EKS cluster" is where a first install actually starts and the chart's docs began one step later. Written from the run that rebuilt this deployment from an empty account, so the `gp2` warning below it is not a caution copied from somewhere: that is what `eksctl` leaves behind, with the in-tree provisioner, unmarked. The note about letting the EBS CSI addon finish is there because creating it by hand while eksctl is creating it fails the cluster create with a message about pod identity associations, which says nothing about the race that caused it. --- charts/openbot/README.md | 52 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/charts/openbot/README.md b/charts/openbot/README.md index 5605b675..e862d6b5 100644 --- a/charts/openbot/README.md +++ b/charts/openbot/README.md @@ -39,6 +39,58 @@ different one with `database.existingSecretKey`. See `vector` extension, both of which a managed database will otherwise fail on in a way that names the wrong problem. +### A cluster from nothing, on EKS + +The three above, as one config and two commands. `eksctl` creates `gp2` and does not mark it +default, and the provisioner it names is the in-tree one current Kubernetes no longer has, so the +StorageClass below is not optional. + +```yaml +# cluster.yaml +apiVersion: eksctl.io/v1alpha5 +kind: ClusterConfig +metadata: { name: openbot, region: us-east-2, version: "1.34" } +iam: { withOIDC: true } +addons: + - name: vpc-cni + - name: coredns + - name: kube-proxy + - name: metrics-server + # Last to be created, because it needs the OIDC provider that needs the control plane. Let it + # finish; creating the same addon by hand while this is running fails the cluster create. + - name: aws-ebs-csi-driver + wellKnownPolicies: { ebsCSIController: true } +managedNodeGroups: + - name: workers + # amd64: the published image has no arm64 variant. See the image note above. + instanceType: t3.large + desiredCapacity: 2 + minSize: 2 + maxSize: 4 + volumeSize: 60 + volumeType: gp3 +``` + +```sh +eksctl create cluster -f cluster.yaml + +kubectl apply -f - <<'EOF' +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: gp3 + annotations: { storageclass.kubernetes.io/is-default-class: "true" } +provisioner: ebs.csi.aws.com +volumeBindingMode: WaitForFirstConsumer +allowVolumeExpansion: true +parameters: { type: gp3 } +EOF +``` + +The database goes in the same VPC, in the private subnets, with a security group admitting 5432 +from the cluster's own security group — `aws eks describe-cluster` names both. Keep it +`--no-publicly-accessible`: the only thing that needs to reach it is in the cluster. + ## Install The bundled database and one administrator, which is the shortest thing that works: From 489323a91d033e6ec99bce73cef724b0595cca81 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 27 Aug 2026 17:56:14 -0700 Subject: [PATCH 06/15] Keep each comment above the code it describes --- scripts/check-new-values-keys.ts | 36 +++++++++++++++++++------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/scripts/check-new-values-keys.ts b/scripts/check-new-values-keys.ts index c64429c6..f87538c1 100644 --- a/scripts/check-new-values-keys.ts +++ b/scripts/check-new-values-keys.ts @@ -264,22 +264,14 @@ for (const { path, variable } of offSwitches) { console.log(`${variable} falls back to ${got}, as values.yaml says`); } } -/* - * The same assertion for the fallbacks that are not env vars. - * - * `offSwitches` above reads a rendered `name:`/`value:` pair, so it can only see a fallback that - * reaches a container's environment. Two do not: the routines schedule and the culler's deadline are - * fields on a CronJob. They were left unchecked as "no drift test yet" and the routines schedule had - * already been wrong once — `* * * * *`, five times more often than anything documents — which is - * the whole argument for asserting it rather than trusting the two comments that describe it. +/** + * What has to be switched on for the workload carrying this fallback to render at all. * - * Nulling the LEAF, not the parent, because that is the shape `--reuse-values` actually produces - * here: an existing release carries `routines.enabled` from the release that introduced it and - * simply has no `schedule` key, so nulling the parent would delete the feature and render nothing - * to assert against. - */ -/* - * The credential that lets `routines.enabled` render, in whichever place this target keeps secrets. + * The culler only needs the sandbox mode it belongs to. Routines additionally need the credential + * the worker presents, and WHERE that lives differs per target: three of the five read secrets from + * a cloud store, where naming `secrets.workerSharedSecret` is not enough and `externalSecrets.data` + * has to name it too. Appended after whatever the target already declares rather than turning the + * store off, so this still renders the target as shipped. */ const targetValues = parse(await Bun.file(valuesFile).text()) as { externalSecrets?: { enabled?: boolean; data?: unknown[] }; @@ -316,6 +308,20 @@ function valueAt(root: unknown, path: readonly string[]): unknown { return path.reduce((here, key) => at(here, key), root); } +/* + * The same assertion for the fallbacks that are not env vars. + * + * `offSwitches` above reads a rendered `name:`/`value:` pair, so it can only see a fallback that + * reaches a container's environment. Two do not: the routines schedule and the culler's deadline are + * fields on a CronJob. They were left unchecked as "no drift test yet" and the routines schedule had + * already been wrong once — `* * * * *`, five times more often than anything documents — which is + * the whole argument for asserting it rather than trusting the two comments that describe it. + * + * Nulling the LEAF, not the parent, because that is the shape `--reuse-values` actually produces + * here: an existing release carries `routines.enabled` from the release that introduced it and + * simply has no `schedule` key, so nulling the parent would delete the feature and render nothing + * to assert against. + */ const fieldFallbacks: ReadonlyArray<{ /** The values key, as `--set` names it. */ path: string; From 745825abd749324b467aad38277dc7c651a0573a Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 27 Aug 2026 17:56:14 -0700 Subject: [PATCH 07/15] Count the chart targets the same way in the changelog as in the README --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c35ba6f8..a645417a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -219,8 +219,8 @@ A Helm chart under `charts/openbot`, Bots and all, and the fixes that installing up. Proven on a real EKS cluster: five workloads, replicas across two nodes, EBS volumes bound, and a Bot opening a real page from inside AWS with the decision in the audit trail. -One chart, four targets: EKS, GKE, AKS and somebody's own cluster, with nothing but values between -them. There is no cloud branching in any template. Every place the clouds genuinely differ is a +One chart, five targets: EKS with a shared browser, EKS with a computer for each Bot, GKE, AKS and +somebody's own cluster, with nothing but values between them. There is no cloud branching in any template. Every place the clouds genuinely differ is a value whose default is what a plain self-hosted cluster does: the cluster's own default StorageClass, no RuntimeClass, a plain Kubernetes Secret, an Ingress. Identity is one `serviceAccount.annotations` map, which is all IRSA, Workload Identity and AKS workload identity are. Secrets are a plain Secret From 65a33836e27c35bc7726bfb078473e0d547bb07e Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 27 Aug 2026 17:56:37 -0700 Subject: [PATCH 08/15] Describe the arm64 failure by what it looks like, not by a string nobody here has seen --- charts/openbot/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openbot/README.md b/charts/openbot/README.md index e862d6b5..fca62cb3 100644 --- a/charts/openbot/README.md +++ b/charts/openbot/README.md @@ -10,8 +10,8 @@ Three things this chart assumes and does not create. **An image the cluster can pull.** A release publishes `ghcr.io/copilotkit/openbot:vX.Y.Z` publicly, and that tag is what `image.tag` wants. It is built for **`linux/amd64` only**, so an arm64 node group — Graviton on EKS, Tau T2A on GKE, Ampere on AKS — cannot run it: the pods sit in -`ImagePullBackOff` and the event says `no match for platform`, which reads like a broken registry -rather than a node pool of the wrong shape. Either run amd64 nodes, or build the image for the +`ImagePullBackOff`, which is the same thing a wrong tag or a missing pull secret looks like, so the +node pool being the wrong shape is the last thing anybody checks. Either run amd64 nodes, or build the image for the architecture you have and push it somewhere the cluster can reach. Check before assuming: ```sh From c0dcbcbc3540ae94a44a428e1dbc58a7ae5f5fb2 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 27 Aug 2026 18:23:26 -0700 Subject: [PATCH 09/15] Name the Bot that handed work over, on the screen that exists to say who did what MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Audit screen's Bot column reads `payload.bot` and renders a dash without it. Both handoff events carried the Bot under `from` and never under `bot`, so on a screen headed "Every action a Bot took" the two handoff rows were the only ones naming no Bot — while `agent.escalated`, written twenty lines away in the same feature, sets it. Found by reading the trail on a real deployment after driving a handoff through it, not from the code. Both tests fail without the change. --- CHANGELOG.md | 8 ++++--- server/src/agents/handoff-runner.ts | 3 +++ server/src/agents/handoff.ts | 5 ++++ server/tests/agent-handoff-runner.test.ts | 29 +++++++++++++++++++++++ server/tests/agent-handoff.test.ts | 7 ++++++ 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a645417a..f32052f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,11 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ### One Bot can hand work to another, and reach a person when no Bot will do A Bot asked something it is not the right Bot for can now put the question to one that is. The -addressed Bot answers as itself, with its own tools and its own knowledge, and the answer arrives in -the conversation the person was already in rather than somewhere they would have to go and look. A -Bot that judges no other Bot will do can instead reach the person who asked it. +addressed Bot answers **as itself, in its own conversation**, with its own tools and its own +knowledge — the asking Bot does not relay text on its behalf, so what you read is the answer that +Bot actually gave rather than another Bot's summary of it. The asking conversation records that the +question was put and to whom. A Bot that judges no other Bot will do can instead reach the person +who asked it. **No Bot may address any other until an administrator says so.** Which Bot may reach which is an ordinary grant, made per Bot, and a Bot with no grant is told it cannot rather than quietly trying. diff --git a/server/src/agents/handoff-runner.ts b/server/src/agents/handoff-runner.ts index 8d57fec3..1950e322 100644 --- a/server/src/agents/handoff-runner.ts +++ b/server/src/agents/handoff-runner.ts @@ -355,6 +355,9 @@ export function createHandoffRunner(options: { targetId: work.toBotId, ...(work.actorId ? { actorUserId: work.actorId } : {}), payload: { + // See the same key on `agent.handoff_offered`: the Audit screen's Bot column reads + // `payload.bot`, so a row without it names no Bot. + bot: work.fromBotId, from: work.fromBotId, to: work.toBotId, run: work.runId, diff --git a/server/src/agents/handoff.ts b/server/src/agents/handoff.ts index ccb1c75e..0e23818c 100644 --- a/server/src/agents/handoff.ts +++ b/server/src/agents/handoff.ts @@ -383,6 +383,11 @@ export function createHandoffDesk(options: { targetId: found.id, ...(from.actorId ? { actorUserId: from.actorId } : {}), payload: { + // The Bot that did this, under the key the Audit screen reads for its Bot column. `from` + // below says the same thing and is what the payload is read by, but the screen renders + // `payload.bot` and nothing else, so without this the two handoff rows are the only Bot + // actions on a screen headed "Every action a Bot took" that name no Bot. + bot: from.botId, from: from.botId, to: found.id, run: from.runId, diff --git a/server/tests/agent-handoff-runner.test.ts b/server/tests/agent-handoff-runner.test.ts index b8252c05..fa7c726e 100644 --- a/server/tests/agent-handoff-runner.test.ts +++ b/server/tests/agent-handoff-runner.test.ts @@ -34,6 +34,10 @@ function runner(options?: { }) { const calls: Array<{ verb: string; key: string; owner?: string }> = []; const events: string[] = []; + const written: Array<{ + eventType: string; + payload: Record; + }> = []; const delivered: Array<{ message: string; assertion: string }> = []; const offered: HandoffWork[] = []; @@ -66,12 +70,17 @@ function runner(options?: { const auditStore: AuditStore = { insert: async (event) => { events.push(event.eventType); + written.push({ + eventType: event.eventType, + payload: (event.payload ?? {}) as Record, + }); }, }; return { calls, events, + written, delivered, offered, runner: createHandoffRunner({ @@ -167,6 +176,26 @@ describe("delivering a hop", () => { expect(events).toContain("agent.handoff_delivered"); }); + /* + * The Audit screen's Bot column reads `payload.bot` and renders a dash without it, so a delivery + * that names the Bot only under `from` is a row saying a handoff happened and not who did it. + * Its sibling `agent.handoff_offered` is asserted the same way in `agent-handoff.test.ts`. + */ + test("a delivery names the Bot that handed the work over", async () => { + const { runner: sweep, written } = runner(); + + await sweep.sweep(); + + const delivery = written.find( + (event) => event.eventType === "agent.handoff_delivered", + ); + expect(delivery?.payload).toMatchObject({ + bot: WORK.fromBotId, + from: WORK.fromBotId, + to: WORK.toBotId, + }); + }); + /* Releasing an unusable row would put it back on the queue for ever. */ test("a row that is not a hop is finished rather than released", async () => { const { runner: sweep, calls } = runner({ diff --git a/server/tests/agent-handoff.test.ts b/server/tests/agent-handoff.test.ts index 0aebe548..da3abae6 100644 --- a/server/tests/agent-handoff.test.ts +++ b/server/tests/agent-handoff.test.ts @@ -317,6 +317,13 @@ describe("handing work to another Bot", () => { from: "assistant", to: "researcher", run: "run-1", + /* + * The Audit screen's Bot column reads `payload.bot` and nothing else, so a row without it + * renders a dash. Every other Bot action sets it — `agent.escalated` one file over does — + * and these two did not, which made the handoff the only thing on a screen headed "Every + * action a Bot took" that named no Bot. Asserted rather than left to the reader of a payload. + */ + bot: "assistant", }); const refused = desk({ granted: false }); From 3f1b781e98bf4a2749eda6b3d6e28c2d43b5d363 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 27 Aug 2026 18:24:43 -0700 Subject: [PATCH 10/15] Name the rollback flag on both Helm majors, since Helm 4 renamed it --- charts/openbot/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/charts/openbot/README.md b/charts/openbot/README.md index fca62cb3..4a13de26 100644 --- a/charts/openbot/README.md +++ b/charts/openbot/README.md @@ -349,5 +349,8 @@ install rather than found as a browser that fails on every page. Migrations run as a `pre-install,pre-upgrade` Job, so no replica ever serves in front of a schema it has not seen. An init container would mean every replica racing to migrate the same database. -Use `helm upgrade --install --atomic` so a failed upgrade rolls back rather than leaving half a -rollout. +Roll back a failed upgrade rather than leaving half a rollout: `helm upgrade --install --atomic` on +Helm 3, and `--rollback-on-failure` on Helm 4, which renamed the flag. Helm 4 still accepts +`--atomic` on `upgrade` as a deprecated alias and prints a warning, so the Helm 3 spelling keeps +working on both today; it is `helm install --atomic` that Helm 4 removed outright, which is one more +reason this is written as `upgrade --install`. From d359f83fbd27fc6a9e411a6eff13baf450c699d9 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 27 Aug 2026 19:09:06 -0700 Subject: [PATCH 11/15] Answer a tool call with the result that follows it, not one stored anywhere in the thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A conversation that used the features this release is named for became permanently unusable, and the only thing it said was that a tool result was missing. `repairUnansweredToolCalls` exists so a stored thread stays provider-valid, and it decided which calls were answered by collecting every tool result in the array regardless of position. Threads do not come back in that order. Read back from the platform after one Bot handed work to another, the result sat three messages AHEAD of the call that produced it, and the same inversion held for `ask_person` and for an MCP tool call; only the computer's own tools came back in order. So the set said "answered", the array was returned untouched, and the provider — which matches a result to the call above it — saw a call with nothing after it and threw `AI_MissingToolResultsError` while converting the prompt. That fails the whole conversation rather than one turn: every later message in that channel died the same way, including "what is 2 plus 2". A call is now answered only by a result later in the array than the call. An early result is MOVED to sit after its call rather than replaced, because it is the real one and says more than this function's apology; only a call with no result anywhere still gets the apology. A result whose call never appears is dropped, because it answers nothing and a provider refuses it for the mirror-image reason. Checked against the 45-message thread this was found in: seven ordering faults before, none after, and the three real results kept. --- app/src/lib/copilot/repair-history.ts | 67 ++++++++++++++++++++++----- app/tests/repair-history.test.ts | 56 ++++++++++++++++++++++ 2 files changed, 112 insertions(+), 11 deletions(-) diff --git a/app/src/lib/copilot/repair-history.ts b/app/src/lib/copilot/repair-history.ts index ad8f3e66..d8a04a3a 100644 --- a/app/src/lib/copilot/repair-history.ts +++ b/app/src/lib/copilot/repair-history.ts @@ -16,9 +16,29 @@ function isToolResult(message: Message): message is Message & ToolResult { } /** - * The same messages, with a result inserted for any tool call that has none. + * The same messages, with every tool call answered by a result that FOLLOWS it. * * Returns the original array when no repair is needed. + * + * POSITION IS THE WHOLE POINT, and it was the half this function did not check. It decided which + * calls were answered by collecting every tool result in the array regardless of where it sat, so a + * result stored BEFORE its own call marked that call answered and the array was returned untouched. + * A provider matches a result to the call above it, so what it saw was a call with nothing after it: + * `AI_MissingToolResultsError`, thrown while converting the prompt, which fails the whole + * conversation rather than one turn. Every later message in that channel then failed the same way, + * including "what is 2 plus 2" — the conversation was dead for good and said only that a tool result + * was missing. + * + * Threads really are stored that way. Read back from the platform after a Bot handed work to another + * Bot, the result sat three messages ahead of the call that produced it, and the same inversion held + * for `ask_person` and for an MCP tool call; only the computer's own tools came back in order. So + * this is not a hypothetical ordering: it is what a transcript looks like after using the features + * this release is named for. + * + * An early result is MOVED rather than replaced, because it is the real one — "Handed to Knowledge" + * says more than this function's apology ever could. Only a call with no result anywhere gets the + * sentence below. A result whose call never appears at all is dropped: it answers nothing, and a + * provider rejects it for the same reason it rejects the mirror image. */ export function repairUnansweredToolCalls( messages: ReadonlyArray, @@ -29,9 +49,28 @@ export function repairUnansweredToolCalls( */ mintId: () => string = newId, ): ReadonlyArray { + const called = new Set(); + /** Answered where a provider can see it: by a result later in the array than the call. */ const answered = new Set(); + /** A real result that arrived before its own call, kept so it can be put back in the right place. */ + const early = new Map(); + /** Results that answer nothing where they sit, and so must not be sent where they sit. */ + const misplaced = new Set(); + for (const message of messages) { - if (isToolResult(message)) answered.add(message.toolCallId); + if (message.role === "assistant") { + for (const call of message.toolCalls ?? []) called.add(call.id); + continue; + } + if (!isToolResult(message)) continue; + const id = message.toolCallId; + if (called.has(id) && !answered.has(id)) { + answered.add(id); + continue; + } + // Before its call, or a second result for a call already answered, or answering no call at all. + if (!early.has(id)) early.set(id, message); + misplaced.add(message); } const missing = messages.some( @@ -39,26 +78,32 @@ export function repairUnansweredToolCalls( message.role === "assistant" && (message.toolCalls ?? []).some((call) => !answered.has(call.id)), ); - if (!missing) return messages; + if (!missing && misplaced.size === 0) return messages; const repaired: Message[] = []; + const filled = new Set(answered); for (const message of messages) { + if (misplaced.has(message)) continue; repaired.push(message); if (message.role !== "assistant") continue; for (const call of message.toolCalls ?? []) { - if (answered.has(call.id)) continue; + if (filled.has(call.id)) continue; // Immediately after the assistant message that made the call, and before any later message: // OpenAI requires the results to follow their calls, and some providers require the order to // match the `tool_calls` array as well. - repaired.push({ - id: mintId(), - role: "tool", - toolCallId: call.id, - content: UNANSWERED, - } as Message); + const moved = early.get(call.id); + repaired.push( + moved ?? + ({ + id: mintId(), + role: "tool", + toolCallId: call.id, + content: UNANSWERED, + } as Message), + ); // A duplicated call id may only receive one repair result. - answered.add(call.id); + filled.add(call.id); } } diff --git a/app/tests/repair-history.test.ts b/app/tests/repair-history.test.ts index 3ee972cf..f4030c16 100644 --- a/app/tests/repair-history.test.ts +++ b/app/tests/repair-history.test.ts @@ -114,6 +114,62 @@ describe("repairing a history before it is sent", () => { expect(results).toHaveLength(1); }); + /* + * The shape a real thread came back in after one Bot handed work to another: the result three + * messages AHEAD of the call that produced it. Collecting results without regard to position + * called that call answered, returned the array untouched, and the provider then refused the whole + * conversation because nothing followed the call. + */ + test("a result stored before its own call is moved after it", () => { + const messages = [ + { id: "u1", role: "user", content: "ask the other Bot" }, + { + id: "t1", + role: "tool", + toolCallId: "c1", + content: "Handed to Knowledge.", + }, + { id: "a1", role: "assistant", content: "I asked it." }, + { + id: "a2", + role: "assistant", + content: "", + toolCalls: [call("c1", "message_bot")], + }, + { id: "u2", role: "user", content: "what is 2 plus 2" }, + ] as Message[]; + + const repaired = repairUnansweredToolCalls(messages, ids); + + expect(repaired.map((message) => message.role)).toEqual([ + "user", + "assistant", + "assistant", + "tool", + "user", + ]); + const result = repaired[3] as Message & { toolCallId: string }; + expect(result.toolCallId).toBe("c1"); + // The real result is moved, not thrown away and apologised for. + expect(result.content).toBe("Handed to Knowledge."); + }); + + test("a result whose call never appears is dropped", () => { + const messages = [ + { id: "u1", role: "user", content: "hello" }, + { id: "t1", role: "tool", toolCallId: "nobody", content: "orphan" }, + { id: "a1", role: "assistant", content: "hi" }, + ] as Message[]; + + const repaired = repairUnansweredToolCalls(messages, ids); + + // A result answering no call is refused by a provider for the mirror-image reason. + expect(repaired.map((message) => message.role)).toEqual([ + "user", + "assistant", + ]); + }); + test("a conversation with no tool calls at all is untouched", () => { const messages = [ { id: "u1", role: "user", content: "hello" }, From 73bbd743d9aa8ffe34c2c7c5c2fc4ba9ff862344 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 27 Aug 2026 19:48:52 -0700 Subject: [PATCH 12/15] Grant one Bot the right to ask another on the Bot's own screen The capability shipped with no way to switch it on. `bot` is a grant kind the store and the API both accept, it had no catalogue entry, nothing in the app rendered it, and the read path dropped it, so an administrator following the configuration reference found no screen and no answer. It belongs on the Bot rather than in the connector catalogue: a catalogue entry has a fixed list of tools somebody else maintains, and the Bots a deployment has are whatever was made here. It is also the question a person asks while looking at a Bot. `GET /api/agents/:id/handoff` reports the grants, whether the person looking may change them, and whether the deployment's caps leave the capability on at all. The last one is reported rather than inferred: with the caps at zero a grant is a row nothing will read, and a switch wired to nothing is worse than no switch. Writing reuses the grant endpoint every other grant uses, so the audit row and the refusals are the ones already in place. The pair is directional and the screen says so, because it is the one thing here that is easy to get backwards: the list is who this Bot may ask, not who may ask it. --- CHANGELOG.md | 4 +- app/src/components/agents/agent-profile.tsx | 8 ++ app/src/components/agents/handoff-panel.tsx | 111 +++++++++++++++++++ app/src/lib/agents/mutations.ts | 36 +++++++ app/src/lib/agents/queries.ts | 26 +++++ charts/openbot/README.md | 6 +- docs/configuration.md | 20 ++-- server/src/agents/routes.ts | 48 +++++++++ server/src/app.ts | 15 +++ server/tests/agent-routes.test.ts | 114 ++++++++++++++++++++ 10 files changed, 369 insertions(+), 19 deletions(-) create mode 100644 app/src/components/agents/handoff-panel.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f32052f7..776c5e1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. A Bot asked something it is not the right Bot for can now put the question to one that is. The addressed Bot answers **as itself, in its own conversation**, with its own tools and its own -knowledge — the asking Bot does not relay text on its behalf, so what you read is the answer that +knowledge. The asking Bot does not relay text on its behalf, so what you read is the answer that Bot actually gave rather than another Bot's summary of it. The asking conversation records that the question was put and to whom. A Bot that judges no other Bot will do can instead reach the person who asked it. @@ -24,7 +24,7 @@ would be a guess about which colleague a person meant. Two ceilings, because a Bot deciding to ask another Bot is a Bot deciding to spend a run: `BOT_HANDOFF_MAX_DEPTH` is how many Bots deep a chain may go and defaults to `1`, and **`0` switches -the capability off entirely** — the tool is not offered rather than offered and refused. +the capability off entirely**: the tool is not offered rather than offered and refused. `BOT_HANDOFF_MAX_PER_RUN` is how many Bots one run may address and defaults to `3`. The Helm chart takes the same two as `config.handoff.maxDepth` and `config.handoff.maxPerRun`. diff --git a/app/src/components/agents/agent-profile.tsx b/app/src/components/agents/agent-profile.tsx index 3e3c0e9d..f74555d6 100644 --- a/app/src/components/agents/agent-profile.tsx +++ b/app/src/components/agents/agent-profile.tsx @@ -4,6 +4,7 @@ import { type ReactNode, useState } from "react"; import { AbstractAvatar } from "@/components/agents/abstract-avatar"; import { AgentFields } from "@/components/agents/agent-fields"; import { CallbackTokenPanel } from "@/components/agents/callback-token-panel"; +import { HandoffPanel } from "@/components/agents/handoff-panel"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; @@ -154,6 +155,13 @@ export function AgentProfile({ agentId }: { agentId: string }) { /> ) : null} + {/* + * Not while editing, for the same reason the panel above is not: the form owns the screen, and + * these switches write immediately rather than on save, which would make one half of an open + * form apply and the other half not. + */} + {isEditing ? null : } + {actionError ? (

{actionError.message} diff --git a/app/src/components/agents/handoff-panel.tsx b/app/src/components/agents/handoff-panel.tsx new file mode 100644 index 00000000..eae600b3 --- /dev/null +++ b/app/src/components/agents/handoff-panel.tsx @@ -0,0 +1,111 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AbstractAvatar } from "@/components/agents/abstract-avatar"; +import { Switch } from "@/components/ui/switch"; +import { setHandoffGrantMutationOptions } from "@/lib/agents/mutations"; +import { + agentHandoffQueryOptions, + agentListQueryOptions, +} from "@/lib/agents/queries"; + +/** + * Which Bots this one may hand work to. + * + * On the Bot's own screen rather than in the connector catalogue: a catalogue entry has a fixed list + * of tools somebody else maintains, and the Bots a deployment has are whatever was made here. It is + * also the question a person asks while looking at a Bot, not while looking at a vendor. + * + * DIRECTIONAL, and said so on the screen, because the pair is the one thing about this that is easy + * to get backwards: this is who this Bot may ask, not who may ask it. + */ +export function HandoffPanel({ agentId }: { agentId: string }) { + const queryClient = useQueryClient(); + const handoff = useQuery(agentHandoffQueryOptions(agentId)); + const agents = useQuery(agentListQueryOptions()); + const setGrant = useMutation(setHandoffGrantMutationOptions(queryClient)); + + if (handoff.isPending || !handoff.data) return null; + const { enabled, canGrant, reachable } = handoff.data; + + /* + * A Bot may not be granted itself, and the server refuses it, so it is not offered here either. + * Hidden Bots are already absent from this list. + */ + const others = (agents.data ?? []).filter( + (candidate) => candidate.id !== agentId, + ); + + // Nothing to say to somebody who cannot change it and has nothing to read. + if (!canGrant && reachable.length === 0) return null; + + return ( +

+

+ Bots it may ask +

+ +

+ {enabled + ? "Work this Bot cannot do itself, it may hand to one of these. The Bot it asks answers in its own conversation, as itself." + : "Handing work between Bots is switched off for this deployment, so none of these takes effect until it is switched back on."} +

+ + {setGrant.error ? ( +

+ {setGrant.error.message} +

+ ) : null} + + {others.length === 0 ? ( +

+ There is no other Bot here to hand work to. +

+ ) : ( +
    + {others.map((candidate) => { + const held = reachable.includes(candidate.id); + return ( +
  • + + + + + {candidate.name} + + + {candidate.title} + + + + + setGrant.mutate({ + agentId, + ref: candidate.id, + granted: next, + }) + } + /> +
  • + ); + })} +
+ )} + + {canGrant ? null : ( +

+ An administrator decides which Bots may be asked. +

+ )} +
+ ); +} diff --git a/app/src/lib/agents/mutations.ts b/app/src/lib/agents/mutations.ts index b0b00436..64114638 100644 --- a/app/src/lib/agents/mutations.ts +++ b/app/src/lib/agents/mutations.ts @@ -113,3 +113,39 @@ export function revokeCallbackTokenMutationOptions(queryClient: QueryClient) { onSuccess: () => invalidateAgents(queryClient), }); } + +/** + * Whether one Bot may hand work to another. + * + * The same `plugin_grants` write every other grant makes, with `kind: "bot"`, so the audit row and + * the refusals are the ones already in place: an administrator only, never a Bot on itself, and + * never onto a Bot that does not exist. + * + * DIRECTIONAL, and the two ids are easy to swap: `agentId` is the Bot doing the asking and `ref` is + * the Bot it may reach. Granted the other way round it reads as working and hands over nothing. + */ +export function setHandoffGrantMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (variables: { + /** The Bot doing the asking. */ + agentId: string; + /** The Bot it may reach. */ + ref: string; + granted: boolean; + }) => { + if (variables.granted) { + await client("/api/plugins/grants", { + method: "POST", + body: { kind: "bot", ref: variables.ref, agentId: variables.agentId }, + fallback: FALLBACK, + }); + return; + } + await client( + `/api/plugins/grants?kind=bot&ref=${encodeURIComponent(variables.ref)}&agentId=${encodeURIComponent(variables.agentId)}`, + { method: "DELETE", fallback: FALLBACK }, + ); + }, + onSuccess: () => invalidateAgents(queryClient), + }); +} diff --git a/app/src/lib/agents/queries.ts b/app/src/lib/agents/queries.ts index ce276d69..eadc2e20 100644 --- a/app/src/lib/agents/queries.ts +++ b/app/src/lib/agents/queries.ts @@ -43,6 +43,22 @@ export const agentKeys = { all: ["agents"] as const, list: (hidden = false) => ["agents", "list", { hidden }] as const, detail: (agentId: string) => ["agents", "detail", agentId] as const, + handoff: (agentId: string) => ["agents", "handoff", agentId] as const, +}; + +/** Which Bots one Bot may hand work to, and whether this deployment lets it. */ +export type HandoffGrants = { + /** + * Whether the capability is switched on at all. + * + * Separate from the grants because the two fail differently: with this false, a grant is a row + * nothing will ever read, so the screen says so rather than offering a switch wired to nothing. + */ + enabled: boolean; + /** Whether the signed-in person may change any of it. Granting is an administrator's. */ + canGrant: boolean; + /** Bot ids this Bot may address today. */ + reachable: string[]; }; export function agentListQueryOptions(hidden = false) { @@ -65,6 +81,16 @@ export function agentQueryOptions(agentId: string) { }); } +export function agentHandoffQueryOptions(agentId: string) { + return queryOptions({ + queryKey: agentKeys.handoff(agentId), + queryFn: (): Promise => + client(`/api/agents/${agentId}/handoff`, "handoff", { + fallback: "Could not load which Bots this one may ask", + }), + }); +} + /** What the server said when it tried the endpoint. */ export type ConnectionVerdict = | { ok: true; events: string[] } diff --git a/charts/openbot/README.md b/charts/openbot/README.md index 4a13de26..53cc82e8 100644 --- a/charts/openbot/README.md +++ b/charts/openbot/README.md @@ -9,7 +9,7 @@ Three things this chart assumes and does not create. **An image the cluster can pull.** A release publishes `ghcr.io/copilotkit/openbot:vX.Y.Z` publicly, and that tag is what `image.tag` wants. It is built for **`linux/amd64` only**, so an -arm64 node group — Graviton on EKS, Tau T2A on GKE, Ampere on AKS — cannot run it: the pods sit in +arm64 node group (Graviton on EKS, Tau T2A on GKE, Ampere on AKS) cannot run it: the pods sit in `ImagePullBackOff`, which is the same thing a wrong tag or a missing pull secret looks like, so the node pool being the wrong shape is the last thing anybody checks. Either run amd64 nodes, or build the image for the architecture you have and push it somewhere the cluster can reach. Check before assuming: @@ -19,7 +19,7 @@ docker manifest inspect ghcr.io/copilotkit/openbot:v0.0.4 | grep architecture ``` **A default StorageClass**, or a named one. Both a Bot's computer and the bundled database ask for -a volume, and a fresh cluster often has no class marked default — see +a volume, and a fresh cluster often has no class marked default. See [Check for a default StorageClass first](#check-for-a-default-storageclass-first), which is the single most common reason a first install comes up with a pod stuck `Pending` and nothing saying why. @@ -88,7 +88,7 @@ EOF ``` The database goes in the same VPC, in the private subnets, with a security group admitting 5432 -from the cluster's own security group — `aws eks describe-cluster` names both. Keep it +from the cluster's own security group. `aws eks describe-cluster` names both. Keep it `--no-publicly-accessible`: the only thing that needs to reach it is in the cluster. ## Install diff --git a/docs/configuration.md b/docs/configuration.md index 6980db95..e9ec67d1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -205,21 +205,13 @@ zero or more: a deployment that typed `two` and silently got the default would b cap. Which Bots may address which is a grant, not a variable, and no Bot may address any other until one -is made. **It has no screen yet.** Every other grant is made at `/admin/plugins`; this one is made -against the API, by an administrator, naming the Bot doing the addressing and the Bot being -addressed: +is made. It is made on the Bot's own screen: open it from **Agents**, and switch on each Bot under +**Bots it may ask**. The pair is directional: that list is who this Bot may ask, not who may ask it, +so letting them ask each other is two switches. Only an administrator may change it; anyone who can +see the Bot can read it. -```sh -# Let `general-assistant` hand work to `knowledge`. -curl -X POST "$OPENBOT_URL/api/plugins/grants" \ - -H 'content-type: application/json' \ - --cookie "$SESSION" \ - -d '{"kind":"bot","ref":"knowledge","agentId":"general-assistant"}' -``` - -`ref` is the Bot that may be reached and `agentId` is the Bot doing the reaching, so the pair is -directional: granting the reverse is a second call. `DELETE` the same three as query parameters -takes it away. A Bot may not be granted itself, and only an administrator may grant at all. +With both caps above at zero the screen says the capability is switched off, because a grant made +then is a row nothing will read. ## Computer and supervisor diff --git a/server/src/agents/routes.ts b/server/src/agents/routes.ts index 50cb872c..060e77af 100644 --- a/server/src/agents/routes.ts +++ b/server/src/agents/routes.ts @@ -142,6 +142,23 @@ export function createAgentRoutes( * address. A hosted deployment sets this and leaves the other off. */ allowedHosts: ReadonlySet = new Set(), + /** + * Which Bots a Bot may hand work to, for the screen that grants it. + * + * A named object rather than another positional argument: every parameter above this one is + * optional, so a misplaced one typechecks and silently does nothing, and this list is already at + * the length where that stops being hypothetical. + * + * Absent in a deployment with no plugin store, which is a deployment where no Bot may address any + * other. The screen is then told the capability is off rather than shown a control that grants + * nothing. + */ + handoff?: { + /** Whether the deployment's own caps leave the capability switched on at all. */ + enabled: boolean; + /** The Bots this one may address today, read per call so a revoked grant stops showing. */ + reachableFrom: (agentId: string) => Promise; + }, ) { const routes = new Hono<{ Variables: AppVariables }>(); @@ -440,6 +457,37 @@ export function createAgentRoutes( } }); + /** + * Which Bots this Bot may hand work to. + * + * On the Bot's own screen rather than under the connector catalogue, because it is a fact about + * this Bot and not about a vendor: the catalogue's entries have a fixed list of tools, and the + * Bots a deployment has are whatever somebody made. + * + * `enabled` is reported separately from the grants, because the two fail differently. A grant with + * the capability switched off is a row in the database that will never be read, and a screen that + * offered it without saying so would be a switch wired to nothing. + */ + routes.get("/:agentId/handoff", requireUser, async (context) => { + const agentId = context.req.param("agentId"); + try { + // Asked of the store, so a Bot somebody may not see is "not found" here as everywhere else, + // rather than a list of who it can reach. + const agent = await store.get(context.var.actor, agentId); + if (!agent) return context.json({ error: "Agent not found." }, 404); + return context.json({ + handoff: { + enabled: handoff?.enabled ?? false, + // Granting is an administrator's, the same as it is on every other grant. + canGrant: context.var.actor.role === "admin", + reachable: handoff ? await handoff.reachableFrom(agentId) : [], + }, + }); + } catch (error) { + return mapStoreError(context, error); + } + }); + return routes; } diff --git a/server/src/app.ts b/server/src/app.ts index c802dcff..20561446 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -764,6 +764,21 @@ export function createApp( // Addresses this deployment named, which is how a hosted one reaches an agent on its own // network without dropping the floor for everything else. config.agentEndpointAllowedHosts, + /* + * What the Bot's own screen needs to show, and change, which Bots it may hand work to. + * + * Read per request rather than captured, for the reason the desk reads it per hop: a grant + * made a minute ago counts and one revoked a minute ago stops counting. Absent with no + * plugin store, which is a deployment where no Bot may address any other. + */ + pluginStore + ? { + enabled: + config.handoff.maxDepth > 0 && config.handoff.maxPerRun > 0, + reachableFrom: (agentId) => + pluginStore.botsReachableFrom(agentId), + } + : undefined, ), ); // Choosing a coworker for an untagged message needs the same permission-filtered roster the diff --git a/server/tests/agent-routes.test.ts b/server/tests/agent-routes.test.ts index 323d860a..73fc40af 100644 --- a/server/tests/agent-routes.test.ts +++ b/server/tests/agent-routes.test.ts @@ -607,3 +607,117 @@ describe("agent route composition", () => { expect(response.status).toBe(404); }); }); + +/* + * The screen that grants one Bot the right to address another reads this, so what it renders is + * decided here rather than in the browser: whether the capability is on at all, and whether the + * person looking may change any of it. + */ +describe("which Bots a Bot may hand work to", () => { + const admin = { + id: "admin-1", + email: "a@openbot.test", + role: "admin", + } as const; + + function appWith( + handoff: Parameters[5], + who: { id: string; email: string; role: "admin" | "user" } = admin, + ) { + const app = new Hono<{ Variables: AppVariables }>(); + const asWho: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", who); + await next(); + }; + app.route( + "/", + createAgentRoutes( + fakeStore(), + asWho, + false, + undefined, + new Set(), + handoff, + ), + ); + return app; + } + + test("reports the grants and that an administrator may change them", async () => { + const app = appWith({ + enabled: true, + reachableFrom: async () => ["knowledge"], + }); + + const body = (await json( + await app.request("/general-assistant/handoff"), + )) as { + handoff: { enabled: boolean; canGrant: boolean; reachable: string[] }; + }; + + expect(body.handoff).toEqual({ + enabled: true, + canGrant: true, + reachable: ["knowledge"], + }); + }); + + test("somebody who is not an administrator may read it and not change it", async () => { + const app = appWith( + { enabled: true, reachableFrom: async () => ["knowledge"] }, + actor, + ); + + const body = (await json( + await app.request("/general-assistant/handoff"), + )) as { + handoff: { canGrant: boolean }; + }; + + expect(body.handoff.canGrant).toBe(false); + }); + + /* + * A deployment with the caps at zero, or with no plugin store to read a grant from, has the + * capability switched off. Reported rather than left to the screen to infer, because a switch + * wired to nothing is the thing this says out loud. + */ + test("says the capability is off when nothing can grant it", async () => { + const app = appWith(undefined); + + const body = (await json( + await app.request("/general-assistant/handoff"), + )) as { + handoff: { enabled: boolean; reachable: string[] }; + }; + + expect(body.handoff.enabled).toBe(false); + expect(body.handoff.reachable).toEqual([]); + }); + + test("a Bot the person may not see is not found, rather than described", async () => { + const app = new Hono<{ Variables: AppVariables }>(); + app.route( + "/", + createAgentRoutes( + fakeStore({ + async get() { + return null; + }, + }), + requireUser, + false, + undefined, + new Set(), + { enabled: true, reachableFrom: async () => ["knowledge"] }, + ), + ); + + const response = await app.request("/somebody-elses/handoff"); + + expect(response.status).toBe(404); + }); +}); From 290f7ea3d2055e11d86b1df00b58cb85d18c3e06 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 27 Aug 2026 19:58:24 -0700 Subject: [PATCH 13/15] Wrap the roster on the width it has, not on the window's A Bot card is a fixed 144px in a four-column grid, so the cards overlap as soon as the column holding them is narrower than the card. Opening a Bot takes that width out of the roster's column at any window size, so the cards behind an open Bot sat on top of each other on an ordinary screen. Tracks sized by `auto-fill` follow the container, which is the thing that actually changes here. The switch on the Bot's screen now reads the way the heading above it does: "Let this Bot ask Knowledge", not "Let Knowledge be asked by this Bot". --- app/src/components/agents/handoff-panel.tsx | 2 +- app/src/routes/_authed/_app/agents/index.tsx | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/components/agents/handoff-panel.tsx b/app/src/components/agents/handoff-panel.tsx index eae600b3..bc299f93 100644 --- a/app/src/components/agents/handoff-panel.tsx +++ b/app/src/components/agents/handoff-panel.tsx @@ -84,7 +84,7 @@ export function HandoffPanel({ agentId }: { agentId: string }) { diff --git a/app/src/routes/_authed/_app/agents/index.tsx b/app/src/routes/_authed/_app/agents/index.tsx index 118ea7f4..aa4b4216 100644 --- a/app/src/routes/_authed/_app/agents/index.tsx +++ b/app/src/routes/_authed/_app/agents/index.tsx @@ -25,6 +25,15 @@ export const Route = createFileRoute("/_authed/_app/agents/")({ component: AgentsScreen, }); +/* + * The roster wraps on the width it actually has, not on the window's. + * + * A card is a fixed 144px, so four fixed columns overlap the moment the column they sit in is + * narrower than the card. That is not a narrow-window case: opening the detail pane takes the width + * out of this column at any window size, so the cards behind an open Bot overlapped each other on a + * perfectly ordinary screen. `auto-fill` tracks the container instead, which is the thing that + * actually changed. + */ function AgentsScreen() { const { new: isCreating, agent: selectedAgentId } = Route.useSearch(); const navigate = Route.useNavigate(); @@ -66,7 +75,7 @@ function AgentsScreen() {
{!!mine?.length && ( -
+
{mine.map((agent, index) => { return ( @@ -91,7 +100,7 @@ function AgentsScreen() {

Explore agents

-
+
{!!explore?.length && explore.map((agent, index) => { return ( From 23843e7f720c68778b5c2de930cbd2692ad44c70 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 27 Aug 2026 20:05:31 -0700 Subject: [PATCH 14/15] Name the Bot on its own screen, rather than calling every one of them Browser Bot The heading was the string "Browser Bot", so the screen called whichever Bot you opened by a name no deployment necessarily has, with the right one already resolved two lines away. It is the defect the route default under it was fixed for and says so in its own comment: a Bot name written into a route is wrong on every fork but the one it came from, and a Bot name written into the markup above it is wrong in the same way. The line under it still describes the screen, because that part is true of every Bot here. --- app/src/routes/_authed/_app/bot.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/routes/_authed/_app/bot.tsx b/app/src/routes/_authed/_app/bot.tsx index b29f1322..7eed66aa 100644 --- a/app/src/routes/_authed/_app/bot.tsx +++ b/app/src/routes/_authed/_app/bot.tsx @@ -31,7 +31,8 @@ function RouteComponent() { const { agent } = Route.useSearch(); const { data: agents, isPending } = useQuery(agentListQueryOptions()); const agentId = agent ?? agents?.[0]?.id; - const known = agents?.some((candidate) => candidate.id === agentId) ?? false; + const bot = agents?.find((candidate) => candidate.id === agentId); + const known = bot !== undefined; if (isPending) return null; if (!agentId || !known) { @@ -50,10 +51,10 @@ function RouteComponent() { * Keyed on the Bot, so the hooks below never see it change under them. They cannot be called * conditionally, and the guards above return before any of them run. */ - return ; + return ; } -function BotChat({ agentId }: { agentId: string }) { +function BotChat({ agentId, name }: { agentId: string; name: string }) { // Tool calls here act on this Bot's own computer. useActiveBot(agentId); /* @@ -77,7 +78,13 @@ function BotChat({ agentId }: { agentId: string }) {
-

Browser Bot

+ {/* + * The Bot this screen is actually showing. A name written into the markup is wrong on + * every deployment whose package did not happen to use it, which is the same defect the + * route default above was fixed for: this screen called whichever Bot you opened + * "Browser Bot", including the one named something else two lines of state away. + */} +

{name}

{/* * Labelled rather than the bare icon button the sidebar uses for its own "start * something new" control: that one opens an empty screen, but this one throws away From eca485f6d52e969c04cdca9c48529f6aae6a1652 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 27 Aug 2026 20:30:00 -0700 Subject: [PATCH 15/15] Say where the Intelligence credentials come from on the way into a cluster The chart refuses to install without `secrets.intelligenceApiKey` and `secrets.licenseToken`, and named neither anywhere a person would find before hitting that refusal: the README's prerequisites did not mention them and values.yaml carried two empty strings with no comment. A laptop is told three times over, in the README, `.env.example` and `start.sh`. `--print` rather than the `--write` the laptop uses, because `--write` puts the token in a local `.env`, which is not where it is going here. --- charts/openbot/README.md | 15 ++++++++++++++- charts/openbot/values.yaml | 3 +++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/charts/openbot/README.md b/charts/openbot/README.md index 53cc82e8..d778f1f6 100644 --- a/charts/openbot/README.md +++ b/charts/openbot/README.md @@ -5,7 +5,7 @@ only difference between them is values. ## What a cluster needs first -Three things this chart assumes and does not create. +Four things this chart assumes and does not create. **An image the cluster can pull.** A release publishes `ghcr.io/copilotkit/openbot:vX.Y.Z` publicly, and that tag is what `image.tag` wants. It is built for **`linux/amd64` only**, so an @@ -18,6 +18,19 @@ architecture you have and push it somewhere the cluster can reach. Check before docker manifest inspect ghcr.io/copilotkit/openbot:v0.0.4 | grep architecture ``` +**Intelligence credentials.** OpenBot requires CopilotKit Intelligence and the chart refuses to +install without `secrets.intelligenceApiKey` and `secrets.licenseToken`. Both come from the CLI, on +any machine with a browser: + +```sh +npx --yes copilotkit@latest login # browser sign-in +npx --yes copilotkit@latest project select # prints the cpk-... runtime key +npx --yes copilotkit@latest license --print # prints the licence token +``` + +`--print` rather than `--write` here: `--write` puts the token in a local `.env`, which is what a +laptop wants and not what you are about to paste into a Secret. The free plan is enough to install. + **A default StorageClass**, or a named one. Both a Bot's computer and the bundled database ask for a volume, and a fresh cluster often has no class marked default. See [Check for a default StorageClass first](#check-for-a-default-storageclass-first), which is the diff --git a/charts/openbot/values.yaml b/charts/openbot/values.yaml index aeb379cf..717bbb46 100644 --- a/charts/openbot/values.yaml +++ b/charts/openbot/values.yaml @@ -359,6 +359,9 @@ secrets: modelApiKey: "" computerToken: "" supervisorToken: "" + # Both from the CopilotKit CLI: `npx --yes copilotkit@latest project select` prints the runtime + # key, and `npx --yes copilotkit@latest license --print` prints the licence. The chart refuses to + # install without them, because there is no mode where this runs with Intelligence missing. intelligenceApiKey: "" licenseToken: "" # Sent to `config.managedAgent.url` on every call. Required when that url is set.