Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
d300ec8
Eight harnesses, each through the package its own maintainers publish
davidmckayv Sep 6, 2026
519522d
Three more harnesses, and Mastra taken off the list until it can be s…
davidmckayv Sep 6, 2026
495af05
Prove the Claude Agent SDK harness on a subscription rather than a key
davidmckayv Sep 6, 2026
7d12010
Find out what actually blocks Mastra, and keep the image that proves it
davidmckayv Sep 6, 2026
480d3ba
Reach Mastra through its own bridge instead of a route in the harness
davidmckayv Sep 7, 2026
f403f8b
Offer Mastra in the picker now that it can be dialled
davidmckayv Sep 7, 2026
62c0cf0
Draw the two screens setup actually asks: which Bot, and which model
davidmckayv Sep 7, 2026
18784fa
Make the model a choice, so a plan and a key cannot both be set
davidmckayv Sep 7, 2026
fef19ed
Sign in to a Claude plan without installing anything
davidmckayv Sep 7, 2026
549a083
Publish the twelve harness images
davidmckayv Sep 7, 2026
a530d75
Offer a ChatGPT plan, and stop calling it an OpenAI-compatible endpoint
davidmckayv Sep 7, 2026
0ac826a
Let a Mastra Bot actually be stored
davidmckayv Sep 7, 2026
5b87219
Let a Mastra Bot be seeded, which is how a Bot arrives
davidmckayv Sep 7, 2026
0d2eb06
Actually submit the code, and say so when it is refused
davidmckayv Sep 7, 2026
db9aa47
Name the images a release actually publishes
davidmckayv Sep 7, 2026
384f7a9
Register the picked harness, by seeding rather than by an API
davidmckayv Sep 7, 2026
b326eaa
Prove the picked harness works, on both credentials, and fix what tha…
davidmckayv Sep 7, 2026
6bd6554
Give setup a welcome, a default Bot, and a look worth the first impre…
davidmckayv Sep 7, 2026
ea12520
Stop writing localhost where something has to dial it
davidmckayv Sep 7, 2026
87d9944
Sign in to a ChatGPT plan from the window
davidmckayv Sep 7, 2026
176dc18
Give the picked Bot the token, and say failures twice
davidmckayv Sep 7, 2026
7a91f7b
Sign in to Intelligence from the window, not from a terminal
davidmckayv Sep 7, 2026
03b1dfe
Offer the key three ways, and sign in for the one that can
davidmckayv Sep 7, 2026
d8e077b
Give a signed-in ChatGPT plan the Codex model, and the store rather t…
davidmckayv Sep 7, 2026
63eb698
Empty the plan token this app has stopped writing
davidmckayv Sep 7, 2026
4825ccb
End the install with a question the Bot has to answer
davidmckayv Sep 7, 2026
3079696
Keep credentials in the machine's own store, not in the .env
davidmckayv Sep 7, 2026
0ddba6f
Give the secondary buttons the class the stylesheet actually defines
davidmckayv Sep 7, 2026
af4ea71
Store a credential through the Keychain itself, not through the secur…
davidmckayv Sep 7, 2026
f04fbe2
Mint the generated secrets once per deployment, not once per start
davidmckayv Sep 7, 2026
b358264
Stop the Bot that was picked, and stop refusing to start because of it
davidmckayv Sep 7, 2026
f8776c1
Make the ChatGPT sign-in work, and say what went wrong when it does not
davidmckayv Sep 7, 2026
9d38e9a
Give the window an Edit menu, and let a plan pick the Bot that can sp…
davidmckayv Sep 8, 2026
4534215
Make the CopilotKit sign-in work, which took four shape mismatches to…
davidmckayv Sep 8, 2026
50b872c
Stop a model name outliving the answer that chose it
davidmckayv Sep 8, 2026
1a801c9
Say why a conversation has no messages, instead of showing a blank wi…
davidmckayv Sep 8, 2026
b0cf273
Install the container engine, instead of telling somebody to go and g…
davidmckayv Sep 8, 2026
9f087f2
Let an endpoint that needs no key be connected
davidmckayv Sep 8, 2026
927aba0
Let a Bot answer from an endpoint that needs no key
davidmckayv Sep 8, 2026
b280799
Send a placeholder key to an endpoint that reads no key
davidmckayv Sep 8, 2026
2008f67
Serve the installed app without a development server
davidmckayv Sep 8, 2026
bce6e5c
Show the CopilotKit sign-in address, the way the plan sign-ins do
davidmckayv Sep 8, 2026
9c631a4
Stop the host processes on Windows, where Stop was leaving them running
davidmckayv Sep 8, 2026
9d07744
Find the host processes by pid file, so the worker is stopped too
davidmckayv Sep 8, 2026
4cc5fc5
Ask the credential store once per run, not once per screen
davidmckayv Sep 8, 2026
fb52a1b
Merge main
davidmckayv Sep 9, 2026
5ea56ee
Scope the Keychain's service name to the Keychain
davidmckayv Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion .github/published-images.json
Original file line number Diff line number Diff line change
@@ -1 +1,19 @@
["agent-computer", "supervisor", "agent-bot", "agent-langgraph", "server"]
[
"agent-computer",
"supervisor",
"agent-bot",
"agent-langgraph",
"server",
"agent-crewai",
"agent-llamaindex",
"agent-agno",
"agent-langgraph-agui",
"agent-adk",
"agent-pydantic-ai",
"agent-microsoft",
"agent-claude-sdk",
"agent-strands",
"agent-ag2",
"agent-langroid",
"agent-mastra"
]
19 changes: 19 additions & 0 deletions agent-adk/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Google ADK, as a Bot. Python rather than Bun because that is what the integration is published in,
# and the protocol is the only thing a Bot has to share with the others.
FROM python:3.12-slim

WORKDIR /app

# Dependencies before source, so editing the Bot does not re-resolve the framework.
COPY agent-adk/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY agent-adk/src ./src

ENV PORT=4208
EXPOSE 4208
# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container
# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot
# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace,
# and Compose is what decides which host addresses it is published on.
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4208"]
6 changes: 6 additions & 0 deletions agent-adk/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
ag-ui-adk
google-adk
litellm
fastapi
python-multipart
uvicorn[standard]
54 changes: 54 additions & 0 deletions agent-adk/src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Google ADK as a Bot, through `ag_ui_adk`, which AG-UI maintains.

ADK is Gemini-first and model-agnostic after that, so the provider stays the person's choice: ADK
reads LiteLLM model strings, and OpenBot writes the one it was told.
"""

import os

from ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from google.adk.agents import Agent
from google.adk.models.lite_llm import LiteLlm

TOKEN_HEADER = "x-openbot-agent-token"


def _model_id() -> str:
provider = (os.environ.get("BOT_PROVIDER") or "openai").strip()
model = (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip()
return model if "/" in model else f"{provider}/{model}"


app = FastAPI()


@app.middleware("http")
async def refuse_without_the_server_token(request: Request, call_next):
if request.url.path != "/health":
expected = (os.environ.get("MANAGED_AGENT_TOKEN") or "").strip()
offered = (request.headers.get(TOKEN_HEADER) or "").strip()
if not expected or offered != expected:
return JSONResponse({"error": "unauthorised"}, status_code=401)
return await call_next(request)


@app.get("/health")
async def health():
return {"ok": True, "harness": "google-adk"}


add_adk_fastapi_endpoint(
app,
ADKAgent(
adk_agent=Agent(
name="openbot",
model=LiteLlm(model=_model_id()),
instruction="Answer the question you are asked, briefly and correctly.",
),
app_name="openbot",
user_id="openbot",
),
path="/",
)
19 changes: 19 additions & 0 deletions agent-ag2/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# AG2, as a Bot. Python rather than Bun because that is what the integration is published in,
# and the protocol is the only thing a Bot has to share with the others.
FROM python:3.12-slim

WORKDIR /app

# Dependencies before source, so editing the Bot does not re-resolve the framework.
COPY agent-ag2/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY agent-ag2/src ./src

ENV PORT=4210
EXPOSE 4210
# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container
# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot
# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace,
# and Compose is what decides which host addresses it is published on.
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4210"]
4 changes: 4 additions & 0 deletions agent-ag2/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ag2[ag-ui,openai]
fastapi
python-multipart
uvicorn[standard]
39 changes: 39 additions & 0 deletions agent-ag2/src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""AG2 as a Bot. AG-UI is an extra in AG2's own package, so `ag2[ag-ui]` is the dependency."""

import os

from ag2 import Agent
from ag2.ag_ui import AGUIStream
from ag2.config import OpenAIConfig
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

TOKEN_HEADER = "x-openbot-agent-token"

agent = Agent(
name="openbot",
prompt="Answer the question you are asked, briefly and correctly.",
config=OpenAIConfig(model=(os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip()),
)
stream = AGUIStream(agent)

app = FastAPI()


@app.middleware("http")
async def refuse_without_the_server_token(request: Request, call_next):
if request.url.path != "/health":
expected = (os.environ.get("MANAGED_AGENT_TOKEN") or "").strip()
offered = (request.headers.get(TOKEN_HEADER) or "").strip()
if not expected or offered != expected:
return JSONResponse({"error": "unauthorised"}, status_code=401)
return await call_next(request)


@app.get("/health")
async def health():
return {"ok": True, "harness": "ag2"}


# `build_asgi` rather than a helper: AG2 hands back a plain ASGI app to mount.
app.mount("/", stream.build_asgi())
19 changes: 19 additions & 0 deletions agent-agno/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Agno, as a Bot. Python rather than Bun because that is what the integration is published in,
# and the protocol is the only thing a Bot has to share with the others.
FROM python:3.12-slim

WORKDIR /app

# Dependencies before source, so editing the Bot does not re-resolve the framework.
COPY agent-agno/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY agent-agno/src ./src

ENV PORT=4203
EXPOSE 4203
# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container
# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot
# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace,
# and Compose is what decides which host addresses it is published on.
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4203"]
5 changes: 5 additions & 0 deletions agent-agno/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
agno[agui]
litellm
fastapi
python-multipart
uvicorn[standard]
59 changes: 59 additions & 0 deletions agent-agno/src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Agno as a Bot.

The AG-UI support is an extra in Agno's own package rather than a separate bridge, so `agno[agui]`
is the whole dependency and `AGUIApp` is the whole integration. Nothing of the protocol is written
here, which is the rule.
"""

import os

from agno.agent import Agent
from agno.db.in_memory import InMemoryDb
from agno.models.litellm import LiteLLM
from agno.os import AgentOS
from agno.os.interfaces.agui import AGUI
from fastapi import Request
from fastapi.responses import JSONResponse

TOKEN_HEADER = "x-openbot-agent-token"


def _model_id() -> str:
"""`provider/model`, which is how litellm addresses one and how OpenBot stores the choice."""
provider = (os.environ.get("BOT_PROVIDER") or "openai").strip()
model = (os.environ.get("BOT_MODEL") or "gpt-5.5").strip()
return model if "/" in model else f"{provider}/{model}"


agent = Agent(
# In memory, because a Bot's history lives in OpenBot's database and not in the harness. Two
# places remembering the same conversation is how they come to disagree.
db=InMemoryDb(),
model=LiteLLM(id=_model_id()),
# No role, goal or backstory invented on somebody's behalf. A Bot answers the question it is
# asked, and anybody who wants a persona sets one in OpenBot where the rest of them live.
instructions="Answer the question you are asked, briefly and correctly.",
)

app = AgentOS(agents=[agent], interfaces=[AGUI(agent=agent)]).get_app()


@app.middleware("http")
async def refuse_without_the_server_token(request: Request, call_next):
"""Everything but `/health` carries the server's token.

`/health` is exempt because Compose polls it before any token exists, and a healthcheck that
authenticates is a container that never reports healthy.
"""
if request.url.path != "/health":
expected = (os.environ.get("MANAGED_AGENT_TOKEN") or "").strip()
offered = (request.headers.get(TOKEN_HEADER) or "").strip()
# Unset means unconfigured, not open.
if not expected or offered != expected:
return JSONResponse({"error": "unauthorised"}, status_code=401)
return await call_next(request)


@app.get("/health")
async def health():
return {"ok": True, "harness": "agno"}
20 changes: 16 additions & 4 deletions agent-bot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import OpenAI from "openai";
import { hasManagedAgentToken } from "../../shared/agent-authorisation";
import { listenPort } from "../../shared/listen-port";
import { toProviderMessages } from "./history";
import { apiKeyOrPlaceholder, keyIsRequired } from "./model-key";

/**
* The built-in Bot is an AG-UI HTTP service registered the same way as any customer-provided Bot.
Expand Down Expand Up @@ -80,15 +81,26 @@ const BASE_URL = process.env.OPENAI_BASE_URL?.trim() || undefined;
* should fail in front of whoever is deploying, not in front of whoever is asking.
*/
const API_KEY = process.env.OPENAI_API_KEY?.trim();
if (!API_KEY) {
/*
* UNLESS AN ENDPOINT WAS NAMED, in which case the endpoint is the model and the key belongs to it.
*
* Ollama, vLLM, LM Studio and llama.cpp all serve this API with no key at all, and the setup window
* offers exactly those by name. Requiring one here refused the whole keyless half of that feature:
* the person filled in an address, the app raised this Bot, and it exited on startup with
* "OPENAI_API_KEY is not set" about a key their endpoint does not have. The two ends of one feature
* disagreeing.
*
* The check still holds for plain OpenAI, which is the case it was written for.
*/
if (!API_KEY && keyIsRequired(BASE_URL)) {
console.error(
"OPENAI_API_KEY is not set. This Bot cannot answer without a model.",
"OPENAI_API_KEY is not set, and no OPENAI_BASE_URL names an endpoint that needs no key. This Bot cannot answer without a model.",
);
process.exit(1);
}

const openai = new OpenAI({
apiKey: API_KEY,
apiKey: apiKeyOrPlaceholder(API_KEY),
baseURL: BASE_URL,
});

Expand Down Expand Up @@ -241,4 +253,4 @@ serve({
},
});

console.info(`agent-bot listening on http://localhost:${PORT}/ag-ui`);
console.info(`agent-bot listening on http://127.0.0.1:${PORT}/ag-ui`);
26 changes: 26 additions & 0 deletions agent-bot/src/model-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Whether this Bot needs a model key, checked before it starts.
*
* Its own module because `index.ts` serves at module scope, so importing it to reach one pure
* function binds a port.
*/

/**
* A key is required unless an endpoint was named to answer instead.
*
* `OPENAI_BASE_URL` set means any endpoint speaking that API, and Ollama, vLLM, LM Studio and
* llama.cpp all serve it with no key. The setup window offers exactly those by name and accepts a
* blank key for them, so requiring one here exited this Bot on startup for every one of them.
*/
export function keyIsRequired(baseUrl: string | undefined): boolean {
return !baseUrl?.trim();
}

/**
* What to hand the SDK, which insists on a string even when the endpoint ignores it.
*
* A placeholder rather than an empty string: empty is a client that cannot be constructed.
*/
export function apiKeyOrPlaceholder(apiKey: string | undefined): string {
return apiKey?.trim() || "no-key-needed";
}
28 changes: 28 additions & 0 deletions agent-bot/tests/model-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import { apiKeyOrPlaceholder, keyIsRequired } from "../src/model-key";

/**
* A named endpoint is a model, and its key belongs to it.
*
* The failure this pins: the setup window's "any OpenAI-compatible endpoint" row takes an address
* with no key, because Ollama and vLLM have none. This Bot then refused to start, saying
* OPENAI_API_KEY was not set, so the keyless half of that feature produced a dead container and a
* red line on the last screen about a key the person's own server does not have.
*/
describe("whether a model key is required", () => {
test("plain OpenAI still needs its key", () => {
expect(keyIsRequired(undefined)).toBe(true);
expect(keyIsRequired("")).toBe(true);
expect(keyIsRequired(" ")).toBe(true);
});

test("an endpoint named instead of OpenAI answers without one", () => {
expect(keyIsRequired("http://127.0.0.1:11434/v1")).toBe(false);
});

test("the SDK is always handed a string", () => {
expect(apiKeyOrPlaceholder(undefined)).toBe("no-key-needed");
expect(apiKeyOrPlaceholder(" ")).toBe("no-key-needed");
expect(apiKeyOrPlaceholder("sk-real")).toBe("sk-real");
});
});
19 changes: 19 additions & 0 deletions agent-claude-sdk/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Claude Agent SDK, as a Bot. Python rather than Bun because that is what the integration is published in,
# and the protocol is the only thing a Bot has to share with the others.
FROM python:3.12-slim

WORKDIR /app

# Dependencies before source, so editing the Bot does not re-resolve the framework.
COPY agent-claude-sdk/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY agent-claude-sdk/src ./src

ENV PORT=4212
EXPOSE 4212
# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container
# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot
# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace,
# and Compose is what decides which host addresses it is published on.
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4212"]
4 changes: 4 additions & 0 deletions agent-claude-sdk/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ag-ui-claude-sdk
fastapi
python-multipart
uvicorn[standard]
Loading