framework-ify. proper agent runtime + adopt ag-ui
CI / Test + coverage (pull_request) Successful in 1m41s

This commit is contained in:
2026-09-21 18:34:19 -09:00
parent 187252177c
commit a3ef6ec077
26 changed files with 1514 additions and 1200 deletions
+74 -39
View File
@@ -48,7 +48,9 @@ docker run --rm -p 3000:3000 tbd
| `POST` | `/oauth/register` | Dynamic client registration (RFC 7591) for MCP clients |
| `GET`, `POST` | `/oauth/authorize` | Where an MCP client sends the user's browser: Gitea login if needed, then a consent page |
| `POST` | `/oauth/token` | Trades an authorization code (with its PKCE verifier) or a refresh token for tokens |
| `POST` | `/api/chat` | **Login required.** Body `{"messages": [{"role": "user", "content": "hi"}]}` (the conversation so far, 1 to 40 messages of up to 8000 characters, roles `user`, `assistant` or `tool`; and optionally `"page"`, the id of the UI page the user is on (`"tic-tac-toe"`, `"ai-setup"`: a lowercase slug of up to 64 characters), which the LLM is told in a system message, and `"tools"`, the page's tools as MCP describes them: see [the chatbot's tools](#the-chatbots-tools)) sends it to the [chatbot's](#chatbot) LLM and **streams the reply back** as server-sent events (`text/event-stream`): `data: {"thinking":"Let me"}` for each piece of the model's reasoning, `data: {"content":"Hel"}` for each piece of the reply, `data: {"tool-calls":[{"id":"call_1","name":"add","arguments":"{\"x\":1,\"y\":2}"}]}` if the model asks for tools to be called, and last `data: {"done":true}` or `data: {"error":"..."}`. Failures before the reply starts are ordinary errors: 400 for an invalid conversation, 502 if the LLM server can't be reached or refuses, 503 if `LMSTUDIO_URL` isn't set |
| `POST` | `/api/chat` | **Login required.** An [AG-UI](https://docs.ag-ui.com) run: body a `RunAgentInput` (`{"messages": [{"id": "m1", "role": "user", "content": "hi"}], ...}`, the conversation so far, 1 to 40 messages of up to 8000 characters, roles `user`, `assistant` or `tool`; optionally `"tools"`, the page's tools as AG-UI describes them: see [the chatbot's tools](#the-chatbots-tools), and `"context"`, ambient information for the run — the id of the UI page the user is on travels as `{"description": "page", "value": "tic-tac-toe"}`) sends it to the [chatbot's](#chatbot) agent and **streams the reply back** as AG-UI server-sent events (`text/event-stream`): `RUN_STARTED`, then `TEXT_MESSAGE_START`/`TEXT_MESSAGE_CONTENT`/`TEXT_MESSAGE_END` for the reply, `REASONING_MESSAGE_START`/`_CONTENT`/`_END` for the model's reasoning, `TOOL_CALL_START`/`_ARGS`/`_END` if it asks for tools to be called, and last `RUN_FINISHED` or `RUN_ERROR`. Failures before the run starts are ordinary errors: 400 for an invalid input, 502 if the LLM server can't be reached or refuses, 503 if `LMSTUDIO_URL` isn't set |
| `POST` | `/a2a` | The [A2A server](#a2a-server): JSON-RPC 2.0, `message/send` only (no streaming; see below). **Needs an OAuth access token**, the same one an MCP client uses (`401` without one) |
| `GET` | `/.well-known/agent-card.json` | The A2A server's [AgentCard](https://a2a-protocol.org): its name, url, capabilities and skills |
| `GET` | `/api-docs` | Swagger UI |
| `GET` | `/swagger.json` | OpenAPI/Swagger spec |
| `GET` | `/auth/login` | Starts the Gitea OAuth login (redirects to Gitea) |
@@ -171,6 +173,32 @@ the token: it is looked up in `ADMIN_USERS` on every request, so removing someon
effect at once. A Gitea account that is disabled keeps working until its refresh token expires (up to 30
days without use) or its grant is deleted, since Gitea isn't asked again after the login.
## A2A server
The server also speaks [A2A](https://a2a-protocol.org) (Agent2Agent) at `/a2a`, for other agents
(Claude Code, Codex, ...) to call directly, the way `/mcp` lets an MCP client call its tools. It's a
second face of the same agent as the chatbot (`tbd.agent.core`), but not tied to a browser page: an
A2A caller has no page open, so `tbd.agent.a2a` gives it the app's *server-side* tools instead —
todo CRUD and math, the same operations `tbd.mcp.tools` exposes to MCP clients, defined again over
`tbd.todo.service`/`tbd.math.core` directly (this ns doesn't depend on plumcp). Its system prompt
(`tbd.agent.instructions/a2a`) is framed for a caller with no page and no visual feedback, not the
chatbot's in-page persona.
```bash
curl https://tbd.example.com/.well-known/agent-card.json # <BASE_URL>/a2a, its capabilities and skills
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"role":"user","kind":"message","messageId":"m1","parts":[{"kind":"text","text":"list my todos"}]}}}' \
https://tbd.example.com/a2a
```
`$TOKEN` is the same OAuth access token an MCP client gets ([above](#connecting-an-mcp-client)):
`wrap-bearer-auth` verifies a token by looking it up, not by which `resource` it names, so a token
minted for `/mcp` works here too. Only `message/send` is implemented — the AgentCard says so
(`capabilities.streaming: false`) — so a call is synchronous: if the agent asks for a tool, this ns
runs it itself immediately (server-side, no browser to hand it to) and asks the agent again, up to 8
rounds, until it answers in text, which comes back as the JSON-RPC result (an A2A `Message`, role
`"agent"`). Streaming for a browser-driven conversation is what [AG-UI](#chatbot) is for.
## Authentication and the admin API
Login is OAuth2 (authorization-code flow) against a [Gitea](https://about.gitea.com/) instance.
@@ -186,7 +214,7 @@ login, **403** for a logged-in non-admin. With no allowlist, nobody is an admin.
| Variable | Purpose |
|----------|---------|
| `GITEA_URL`, `GITEA_CLIENT_ID`, `GITEA_CLIENT_SECRET` | The Gitea OAuth2 application. Without all three (and `BASE_URL`), `/auth/login` answers 503 |
| `BASE_URL` | Public URL of this app; an `https://` one also marks the session cookie `Secure`. It is also the address of the MCP server: the OAuth issuer, and what MCP clients are told to use (`<BASE_URL>/mcp`) |
| `BASE_URL` | Public URL of this app; an `https://` one also marks the session cookie `Secure`. It is also the address of the MCP and A2A servers: the OAuth issuer, what MCP clients are told to use (`<BASE_URL>/mcp`), and the A2A AgentCard's `url` (`<BASE_URL>/a2a`) |
| `ADMIN_USERS` | Comma-separated Gitea usernames (case-insensitive) allowed to use the admin API |
| `SESSION_SECRET` | Signs and encrypts session cookies. Unset = a random key per start, so logins don't survive a restart |
| `DB_PATH` | The app's SQLite file: todos, and the MCP OAuth server's registered clients, codes and tokens (default `data/tbd.db`). Mount a volume for it in Docker, or todos are lost and every MCP client has to be approved again after each restart |
@@ -245,14 +273,17 @@ in another stage of the same Dockerfile (`--target ui-test`), which the server i
### Chatbot
The green square opens a small chat panel. What you type goes to `POST /api/chat`, and the backend
forwards the conversation to an [LM Studio](https://lmstudio.ai) server (its OpenAI-compatible
`/v1/chat/completions`) and returns the reply. The browser never talks to the LLM directly, so the
server's address (and key) stay on the backend. The reply is streamed: the panel shows "Thinking…" until the
model's first word (a reasoning model can take a while), then the reply appears as it is generated. What
the model reasons is never shown, only that it is thinking; the tool calls (see below) show as small grey
lines between the replies, in full, however long, and the conversation scrolls. The conversation lives in the page only (a reload or logging out clears it), and the UI sends
the last 40 messages of it. Chat needs a login, like the todos: every message costs the LLM server's time.
The green square opens a small chat panel. What you type goes to `POST /api/chat`, an
[AG-UI](https://docs.ag-ui.com) run; the backend's agent (`tbd.agent.core`, a
[LangChain4j](https://docs.langchain4j.dev/) streaming chat model) forwards the conversation to an
[LM Studio](https://lmstudio.ai) server (its OpenAI-compatible `/v1/chat/completions`) and streams
the reply back as AG-UI events. The browser never talks to the LLM directly, so the server's address
(and key) stay on the backend. The panel shows "Thinking…" until the model's first word (a reasoning
model can take a while), then the reply appears as it is generated. What the model reasons is never
shown, only that it is thinking; the tool calls (see below) show as small grey lines between the
replies, in full, however long, and the conversation scrolls. The conversation lives in the page only
(a reload or logging out clears it), and the UI sends the last 40 messages of it. Chat needs a login,
like the todos: every message costs the LLM server's time.
The conversation scrolls. It follows the reply while you are at its end; scroll up to read back and it
stays put until you send a message or scroll down again. Drag the panel's top right corner to make it
@@ -262,35 +293,36 @@ wider or taller (the size is kept while the page is open, through Clear and clos
```bash
curl -N -H "Cookie: $COOKIE" -H 'Content-Type: application/json' \
-d '{"messages":[{"role":"user","content":"hi"}]}' http://localhost:3000/api/chat
-d '{"messages":[{"id":"m1","role":"user","content":"hi"}]}' http://localhost:3000/api/chat
```
#### The chatbot's tools
The chatbot can use the page. The [browser tools](#browser-mcp-tools-webmcp) (the calculator, the todos,
tic-tac-toe) are registered in the page's *model context*, `ui/src/tbd/ui/model_context.cljs`, which is
what WebMCP's `navigator.modelContext` is to an agent outside the page. The chat is that agent, embedded:
what WebMCP's `navigator.modelContext` is to an agent outside the page. The chat is that agent, embedded,
and this is exactly the shape of AG-UI's **frontend tools**: the agent asks, the browser runs them.
1. Each `POST /api/chat` carries `"page"`, the page the user is on, and `"tools"`: the tools' metadata, exactly what an MCP server answers
`tools/list` with (`{"name", "description", "inputSchema"}`, the schema being JSON Schema).
The backend gives it to the LLM as OpenAI-style `tools` (LM Studio's own tool use), and stays stateless.
Only the tools of the page being shown are listed: a tool registered with `:pages #{:calculator}` is
offered on the Calculator page and nowhere else (tic-tac-toe's tools are the Tic-tac-toe page's, the
todos' are the Todos page's), and one with no `:pages` (`ping`, `whoami`, `navigate`) on every page. The
list follows the page the user is on each time the conversation is sent (so does `"page"`, which the
backend puts in the system message ahead of the conversation for the LLM to know where the user is,
with the names of the tools it has),
and a call for a tool the page doesn't offer is refused with an error the LLM reads. The LLM
can move between pages with `navigate`; its next request then carries the new page and that
page's tools. An agent outside the page, through WebMCP, is still offered all of them.
2. The LLM never runs anything. When it wants a tool, it says so: the reply ends with a
`{"tool-calls": [...]}` event (the calls are put together from the pieces the model writes, the
`arguments` being the JSON text it wrote). The backend only relays them.
1. Each `POST /api/chat` carries `"tools"`, the ones the page currently offers, as AG-UI describes them
(`{"name", "description", "parameters"}`, the schema being JSON Schema), and `"context"` naming the
page (`{"description": "page", "value": "tic-tac-toe"}`). The backend converts them to LangChain4j's
typed tool specifications for the LLM, and stays stateless. Only the tools of the page being shown are
listed: a tool registered with `:pages #{:calculator}` is offered on the Calculator page and nowhere
else (tic-tac-toe's tools are the Tic-tac-toe page's, the todos' are the Todos page's), and one with no
`:pages` (`ping`, `whoami`, `navigate`) on every page. The list follows the page the user is on each
time the conversation is sent (so does the page's name, which the backend's agent puts in the system
message ahead of the conversation, with the names of the tools it has), and a call for a tool the page
doesn't offer is refused with an error the LLM reads. The LLM can move between pages with `navigate`;
its next request then carries the new page and that page's tools. An agent outside the page, through
WebMCP, is still offered all of them.
2. The LLM never runs anything. When it wants a tool, it says so: the reply ends with
`TOOL_CALL_START`/`TOOL_CALL_ARGS`/`TOOL_CALL_END` events, one triple per call (`TOOL_CALL_ARGS`'s
`delta` being the JSON text the model wrote). The backend only relays them.
3. The page intercepts them and calls the tools right there (one after the other), through the same
handlers WebMCP uses, so they act on the page as a user would and you see it happen. The chat panel
shows each call and its result in small print between the messages.
4. The results go back as messages with the role `tool` (`{"role": "tool", "tool-call-id": "call_1",
"content": "..."}`, after the assistant message that asked, which carries `"tool-calls"`), in
4. The results go back as messages with the role `tool` (`{"role": "tool", "toolCallId": "call_1",
"content": "..."}`, after the assistant message that asked, which carries `"toolCalls"`), in
another request with the conversation so far, for the LLM to go on from, until it answers with words.
A call that can't be made (a tool that isn't there, arguments that aren't a JSON object, a tool that
@@ -298,21 +330,23 @@ fails) is a result with the reason, for the model to read and try again from. To
stops calling tools from acting on your page for ever, one message of yours gets it at most 12 calls;
then the chat stops, and says so. Results are cut to the 8000 characters the server accepts.
The chatbot is told to be an agent, not a conversationalist: the system message (`tbd.agent.prompt/chat`, in
`shared/tbd/agent/prompt.cljc`, which `default-system-prompt` in `src/tbd/chat/core.clj` is) says that what it
The chatbot is told to be an agent, not a conversationalist: its system prompt
(`tbd.agent.instructions/chat`, in `src/tbd/agent/instructions.clj` — a JVM-only namespace, never on the
UI's classpath, so the chatbot's own instructions are never bundled into the browser) says that what it
does shows on your screen at once, so it should call a tool in the same turn rather than offer to, chain
calls (navigate, list, then act), take obvious defaults instead of asking, and answer in one short
sentence, since the call and its result are already shown. If a model still answers requests with offers,
that prompt (and its four examples, which name tools) is what to tune for it; the tool descriptions in
`webmcp.cljs` are the other lever. The same file gives an MCP client outside the page the same
instructions, [see below](#browser-mcp-tools-webmcp).
sentence, since the call and its result are already shown. It reuses the wording that genuinely is shared
with an outside agent (`act`, `work-it-out`, `tic-tac-toe-rules`, `examples`) from `tbd.agent.prompt` (the
shared `.cljc`, [see below](#browser-mcp-tools-webmcp)) rather than duplicating it. If a model still
answers requests with offers, that's what to tune for it; the tool descriptions in `webmcp.cljs` are the
other lever.
To try it, use a model that supports tool use in LM Studio, log in, and ask the chat something the page
can do: *"add 2 and 3 with the calculator"*, *"add a todo to buy milk"*, *"play tic-tac-toe with me, you're O"*.
A reverse proxy in front of tbd must not buffer responses, or the reply arrives all at once at the end
(the server sends `X-Accel-Buffering: no`, which nginx obeys; for others, turn buffering off for `/api/chat`).
The `thinking` events (the reasoning) also keep the connection from looking idle to a proxy while the model reasons; the UI drops them.
The `REASONING_MESSAGE_CONTENT` events also keep the connection from looking idle to a proxy while the model reasons; the UI drops their text, only using them to show "Thinking…".
Anything that goes wrong is logged on the server's stdout, with a `[tbd] chat:` prefix.
Point the backend at your instance with environment variables (see [.env-template](.env-template)):
@@ -483,11 +517,12 @@ src/tbd/
<group>/schema.clj Malli schemas of the group's input, shared by its routes and the MCP tools
auth/ Gitea OAuth (core), session + admin middleware (middleware), /auth routes
mcp/ MCP server (PluMCP) mounted at `/mcp`: tools (tools.clj), Malli-to-tool glue (schema.clj) and the Ring handler (routes.clj)
oauth/ tbd as the OAuth authorization server for MCP clients: rules (core), SQLite storage (db), `/oauth` + discovery routes, and the bearer-token middleware guarding `/mcp` (middleware)
chat/ The chatbot: request to the LM Studio server, tool calls included (core), the request's Malli schema (schema), `/api/chat` route, per-turn logging and loop detection (trace)
oauth/ tbd as the OAuth authorization server for MCP and A2A clients: rules (core), SQLite storage (db), `/oauth` + discovery routes, and the bearer-token middleware guarding `/mcp` and `/a2a` (middleware)
agent/ The agent: a LangChain4j streaming chat model (core), JSON-Schema translation for tool specs (json_schema), its system prompts (instructions), and the A2A server mounted at `/a2a` (a2a)
chat/ /api/chat: the AG-UI wire protocol (routes), the request's Malli schema (schema), per-turn logging and loop detection (trace) — the agent itself is agent/core.clj
nrepl/ On-demand nREPL server (core) and the admin-only `/nrepl` routes
todo/ Per-user todos: pure rules (core), Malli schemas of the input (schema), SQLite storage (db), `/api/todos` routes
shared/ .cljc read by both the server and the UI (tbd.agent.prompt: the instructions an LLM is given)
shared/ .cljc read by both the server and the UI (tbd.agent.prompt: the instructions an outside agent, through WebMCP, is given — the chatbot's own system prompt is server-only, in src/tbd/agent/instructions.clj)
ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,webmcp,model_context,core}.cljs, tests in ui/test/
.env-template Settings the server reads from the environment; copy to .env (git-ignored)
resources/public/ index.html (the shell served at /) and js/ (compiled UI output, git-ignored)