# tbd A small Clojure HTTP API built on [Ring](https://github.com/ring-clojure/ring) (Jetty) and [reitit](https://github.com/metosin/reitit), with a ClojureScript [re-frame](https://github.com/day8/re-frame) front end (`ui/`), Swagger UI docs, unit tests, dockerized integration tests, and joint line coverage. ## Requirements - Docker, for the integration tests and the `script/docker-*` tasks (all CI needs) - For running tasks natively (optional): [Clojure CLI](https://clojure.org/guides/install_clojure) (tools.deps) with JDK 21+, [Node](https://nodejs.org/) 20+ (22.22+ for the UI tests; with a JDK) for the UI build, and [Babashka](https://babashka.org/) for `script/coverage-report` ## Running ```bash script/ui-build # compile the UI (once, and after UI changes) script/server [port] ``` The server listens on port `3000` by default; pass a port argument or set `PORT` to change it (the argument wins): ```bash script/server 8080 PORT=8080 script/server ``` This is a thin wrapper around `clojure -X:run`, which also honors `PORT`. Then open http://localhost:3000 for the UI. Without `script/ui-build` the server still starts, but `/` shows a placeholder. Or build and run the container: ```bash docker build -t tbd . docker run --rm -p 3000:3000 tbd ``` ## API | Method | Path | Description | |--------|------|-------------| | `GET` | `/` | The UI: an HTML shell that loads the compiled ClojureScript from `/js/main.js` | | `GET` | `/api/ping` | Health check, returns `pong` | | `GET` | `/api/add?x=2&y=3` | Adds two numbers: `{:result 5}` (EDN by default, see [Formats](#formats)) (400 if either is missing or not a number) | | `POST`, `DELETE` | `/mcp` | The [MCP server](#mcp-server) (JSON-RPC over Streamable HTTP; `DELETE` ends a session; `GET` is `405`). **Needs an OAuth access token** (`401` without one) | | `GET` | `/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server` | The [MCP OAuth](#connecting-an-mcp-client) discovery documents (RFC 9728 and 8414) | | `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.** 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) | | `GET` | `/auth/callback` | OAuth redirect target: completes the login, sets the session cookie, redirects to `/` | | `POST` | `/auth/logout` | Ends the session | | `GET` | `/auth/me` | The logged-in user, `{:login "alice" :id 7 :admin? true}` (401 if not logged in) | | `GET` | `/api/todos` | **Login required.** Your todos: `{:todos [{:id "3f2b8c1e-5a47-4d0e-9b6a-1c2d3e4f5a6b" :owner-id 7 :title "buy milk" :done false :created-at 1700000000000 :updated-at 1700000000000}]}` | | `POST` | `/api/todos` | **Login required.** Body `{"title": "buy milk"}` creates a todo and returns it with `201` (400 if the title is blank or over 200 characters) | | `GET` | `/api/todos/:id` | **Login required.** One todo (404 if it doesn't exist or isn't yours) | | `PATCH` | `/api/todos/:id` | **Login required.** Body with `title` and/or `done` (`{"done": true}`) updates the todo and returns it (400 for an empty or invalid change, 404 as above) | | `DELETE` | `/api/todos/:id` | **Login required.** Deletes the todo: `{:deleted true}` (404 as above) | | `GET` | `/nrepl` | **Admin only.** Whether the nREPL debug server is on: `{:enabled false}`, or `{:enabled true :bind "127.0.0.1" :port 7888}` | | `POST` | `/nrepl` | **Admin only.** Body `{"enabled": true}` (JSON or EDN) starts the nREPL debug server, `false` stops it; returns `{:enabled true :bind "127.0.0.1" :port 7888}` | ```bash curl localhost:3000/api/ping # pong curl 'localhost:3000/api/add?x=2&y=3' # {:result 5} curl 'localhost:3000/api/add?x=2&y=3' -H 'Accept: application/json' # {"result":5} ``` ## Formats The API speaks EDN, JSON and Transit (`application/transit+json`, `application/transit+msgpack`). Responses follow the request's `Accept` header, and request bodies are read by their `Content-Type`. **EDN is the default**: with no `Accept` (or `*/*`, or a type the API can't produce) you get EDN, and a body sent with no `Content-Type` is read as EDN. Errors (400, 401, 403, ...) use the same format as everything else. Two exceptions: `/api/ping` is always `text/plain`, and `/swagger.json` is always JSON. Handlers return plain data (`(tbd.http/respond 200 {:result 5})`), and the muuntaja middleware in `tbd.core` encodes it; don't `pr-str` a body yourself. ## Todos Each logged-in user has their own todo list, keyed by their Gitea user id. Every query is filtered by that id, so users can't see or change each other's todos; someone else's todo answers `404`, exactly like a missing one. Without a login the todo routes answer `401`. The operations themselves (ownership, validation, storage) live in `src/tbd/todo/service.clj`, which the HTTP routes and the [MCP tools](#mcp-server) both call, so they behave the same and are tested once; a route or tool only translates its request into a service call and the result into a response, and never touches the database. Everything the app stores (todos, and the MCP OAuth state) lives in one local [SQLite](https://www.sqlite.org/) file, `data/tbd.db` by default (git-ignored), which is created and migrated on first use. Each group owns its own tables (`tbd.todo.db`, `tbd.oauth.db`); `tbd.sqlite` opens the file and runs their migrations. Set `DB_PATH` to put it elsewhere. In Docker, mount a volume at that path (e.g. `docker run -v tbd-data:/app/data ...`), or the todos and MCP approvals are lost with the container. Every id the app issues (a todo's, an OAuth grant's) is a UUID, never a number: a model that reads `7` as a todo's id mixes it up with a 7 in a title. (`owner-id` is the user's id in Gitea, which it issues, so it stays a number.) A database from before that is migrated when it is opened: rows keep their order and get UUIDs, so the ids in URLs or notes from before no longer work. ```bash # after logging in in a browser, copy the `tbd-session` cookie value: curl -XPOST localhost:3000/api/todos -H 'Cookie: tbd-session=...' \ -H 'Content-Type: application/json' -d '{"title": "buy milk"}' curl localhost:3000/api/todos -H 'Cookie: tbd-session=...' ``` ## MCP server The server also speaks [MCP](https://modelcontextprotocol.io/) (Model Context Protocol) at `/mcp`, built on [PluMCP](https://github.com/plumce/plumcp) and running inside this same process; there is nothing extra to start. It uses the Streamable HTTP transport, and, like the todo API, it acts **as the logged-in user**: a client has to be approved by a user first (see below), and from then on every tool call knows who is calling. ```bash claude mcp add --transport http tbd https://tbd.example.com/mcp # /mcp ``` The first time the client connects it gets a `401`, opens your browser, you log in with Gitea as usual and approve the client, and the client keeps its tokens from then on. It needs the [Gitea login](#authentication-and-the-admin-api) to be configured, including `BASE_URL` (which must be the address the client uses; without them `/mcp` answers `503` rather than being open). It offers these tools: `ping`, `add` (the same logic as `/api/add`), `whoami` (which user this connection acts as, and whether they are an admin), and, on the caller's own todos (the same ones as `/api/todos`), `list_todos`, `get_todo`, `create_todo`, `update_todo` and `delete_todo`. To add a tool, add a `tool` to `src/tbd/mcp/tools.clj`; it should call the group's service (or pure `core` functions), not a database, and if the group has no service yet, that is the thing to add, so the HTTP routes can share it. Its arguments are described by the group's [Malli](https://github.com/metosin/malli) schema in `src/tbd//schema.clj`, the same schema the HTTP routes use for their parameters, so a shape of data is written down once: the tool's input schema is generated from it, and the arguments a client sends are coerced and validated against it (plumcp only checks that required ones are present). A tool that touches a user's data or does something admin-only gets the caller from `current-user` there (`{:login :id :admin? :client-id}`), and must not trust anything else about who is asking. (The UI has tools of its own for a browser agent, [see below](#browser-mcp-tools-webmcp); they are not part of this server.) Sessions are kept in memory, so they end when the server restarts (clients just re-initialize; their tokens are in the database and keep working). Server-to-client streaming (`GET /mcp`) is deliberately off: nothing here pushes messages, and the response would hang under Jetty. PluMCP needs JDK 21+. ### Connecting an MCP client tbd is its own OAuth 2.1 authorization server for `/mcp` (in `src/tbd/oauth/`), with the Gitea login as the way a user proves who they are. Gitea's own tokens never reach an MCP client, and the tokens tbd issues are worth nothing anywhere else. The flow is the one the [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) describes, so any compliant client does it by itself: 1. `POST /mcp` with no token gets `401` and `WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-protected-resource/mcp"`. 2. The client reads that document, then `/.well-known/oauth-authorization-server`, and registers itself at `/oauth/register` (dynamic client registration, public clients only). 3. It opens `/oauth/authorize` in the browser (authorization-code flow, PKCE `S256` required). If you aren't logged in you go through the Gitea login first and come back; then a consent page names the client and the address you'll be sent back to, and asks you to **Approve** or **Deny**. 4. The client trades the code at `/oauth/token` for an access token (1 hour) and a refresh token (30 days, rotated on every use), and sends `Authorization: Bearer ...` on every `/mcp` request. The consent page is what stops a stranger's client (anyone can register one) from getting a token by sending you a link: nothing is issued without your click. Redirect URIs must be `https`, `http` on `localhost`/`127.0.0.1`/`[::1]` (the port may vary), or an app's own scheme (`cursor://...`). What's stored is in the app's database (`DB_PATH`): registered clients, and for each approval the user and a *hash* of the current access and refresh tokens. To cut a client off, delete its row from `grants`. Admin-ness isn't in the token: it is looked up in `ADMIN_USERS` on every request, so removing someone from the list takes 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 # /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. Anyone with a Gitea account can log in, but routes marked `:admin? true` (currently `GET` and `POST /nrepl`) also require the user to be on the `ADMIN_USERS` allowlist: **401** without a login, **403** for a logged-in non-admin. With no allowlist, nobody is an admin. 1. In Gitea (Settings > Applications) create an OAuth2 application, a confidential client, with the redirect URI `/auth/callback`. 2. `cp .env-template .env` and fill it in. `script/server` loads `.env`; for Docker use `docker run --env-file .env`. `.env` is git-ignored and kept out of the image. | 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 and A2A servers: the OAuth issuer, what MCP clients are told to use (`/mcp`), and the A2A AgentCard's `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 | | `LMSTUDIO_URL`, `LMSTUDIO_MODEL`, `LMSTUDIO_API_KEY`, `LMSTUDIO_TIMEOUT_SECONDS` | The LM Studio server behind the [chatbot](#chatbot). Only the URL is needed | | `CHAT_TRACE_FILE` | Optional. A file to append a JSON trace of every chatbot turn to, [with the model's full reasoning and replies](#watching-the-chatbot-think). Off by default | | `NREPL_PORT`, `NREPL_BIND` | Where the debug nREPL listens (default `7888` on `127.0.0.1`) | Sessions live in an encrypted, signed, `HttpOnly`, `SameSite=Lax` cookie and expire after 8 hours (enforced server-side). The cookie is stateless, so `POST /auth/logout` clears it in the browser but can't revoke a copy someone already captured; it just expires on its own. ### The nREPL endpoint ```bash # after logging in in a browser, copy the `tbd-session` cookie value: curl -XPOST localhost:3000/nrepl -H 'Cookie: tbd-session=...' \ -H 'Content-Type: application/json' -d '{"enabled": true}' clj -Sdeps '{:deps {nrepl/nrepl {:mvn/version "1.2.0"}}}' -M -m nrepl.cmdline --connect --host 127.0.0.1 --port 7888 ``` nREPL has no authentication and evaluates arbitrary code, so enabling it is effectively remote code execution for whoever can reach the port. It therefore binds to loopback by default; reach it through an SSH tunnel (or `docker exec`) rather than setting `NREPL_BIND=0.0.0.0`. Each enable/disable is logged with the admin's Gitea username. It stays on until an admin disables it (or the server restarts), so switch it off when you're done. ## UI `ui/` is a [re-frame](https://github.com/day8/re-frame) app (ClojureScript, React 19 via [Reagent](https://reagent-project.github.io/)) built with [shadow-cljs](https://github.com/thheller/shadow-cljs). It has four pages, as tabs across the top. Each has its own address in the URL's fragment (`/#/tic-tac-toe`, `/#/calculator`, `/#/todos`, `/#/ai-setup`), so the server only ever serves `/`, and a page can be bookmarked or reloaded. | Tab | What's on it | |-----|--------------| | Tic-tac-toe | Two players on one board, all client side | | Calculator | A small form that calls `GET /api/add` and shows the sum (or the server's validation error) | | Todos | Your todo list (add, tick off, delete), backed by `/api/todos`. The tab only shows once you're logged in; its address shows a "Log in" prompt otherwise | | AI setup | The `claude mcp add ...` command to copy (with this page's own address in it) and the steps to sign in, for adding the [MCP server](#mcp-server) to Claude Code; and how to connect an MCP client to the page's own [browser tools](#browser-mcp-tools-webmcp) | A "Log in" / "Log out" link sits in the footer row next to the API docs links. A green square in the bottom left corner (on every page, opposite the blue WebMCP one) opens the [chatbot](#chatbot). shadow-cljs compiles it into `resources/public/js/` (git-ignored). The server serves that directory at `/js`, and serves `resources/public/index.html`, the shell that mounts the app, at `/`. | Task | What it does | |------|--------------| | `script/ui-build` | Optimized release build into `resources/public/js` | | `script/ui-watch` | Rebuilds on every change, with hot reload; run `script/server` alongside and refresh the page | The Docker image builds the UI in its own stage, so `docker build` needs no local Node. The UI's tests run in another stage of the same Dockerfile (`--target ui-test`), which the server image doesn't include. ### Chatbot 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 wider or taller (the size is kept while the page is open, through Clear and closing the panel). `curl -N` shows the stream (`-N` turns off curl's own buffering; use your session cookie): ```bash curl -N -H "Cookie: $COOKIE" -H 'Content-Type: application/json' \ -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, 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 `"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", "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 fails) is a result with the reason, for the model to read and try again from. To keep a model that never 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: 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. 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 `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)): ```bash LMSTUDIO_URL=http://192.168.1.20:1234 script/server ``` Only `LMSTUDIO_URL` is required (the address of LM Studio's server, without `/v1`; for a remote instance turn on "Serve on Local Network" there). `LMSTUDIO_MODEL` picks a model by id (default: the one LM Studio has loaded), `LMSTUDIO_API_KEY` is sent as a bearer token if the server wants one, and `LMSTUDIO_TIMEOUT_SECONDS` (default 120) is how long the LLM server may stay silent, before it starts answering or between two pieces of it, before the server gives up. Without `LMSTUDIO_URL` the server still starts (it says so), and the chat panel shows "chat is not configured". #### Watching the chatbot think Every request to `/api/chat` is one turn of the model, and every turn is logged on stdout: one `turn-start` line when it goes out and one `turn-end` line when it ends. The tool loop runs in the browser, so the server sees each tool result as a fresh request; the turns of one loop are tied together by a `run` id (a hash of the user's last message), with `step` counting them from 0. ``` [tbd] chat turn-start req=9c1e4a07 run=d4a6cdb3 step=3 page=tic-tac-toe model=qwen messages=8 tools=6 prompt-chars=1180 run-calls=3 [tbd] chat loop-suspect req=9c1e4a07 run=d4a6cdb3 step=3 kind=repeated-call tool=get_tic_tac_toe count=3 args={} [tbd] chat loop-suspect req=9c1e4a07 run=d4a6cdb3 step=3 kind=repeated-thinking count=8 text="Let me check the board again." [tbd] chat turn-end req=9c1e4a07 run=d4a6cdb3 step=3 outcome=tool-calls duration-ms=18040 ttft-ms=2210 think-ms=15100 events=1830 thinking-chars=9210 content-chars=0 calls="get_tic_tac_toe{}" flags=repeated-thinking ``` `turn-end` says how it ended (`outcome`: `done`, `tool-calls`, `error`, `upstream-error`, or `aborted` when the browser went away), how long it took (`ttft-ms` is the wait for the first piece, which includes the LLM reading the prompt; `think-ms` the time spent reasoning before answering), how much it wrote (`events` is close to a token count), and `finish-reason` and token counts when the LLM server sends them. `flags` is the grep handle for a stuck model, with a `loop-suspect` line for each trip as it happens: | Flag | Means | | --- | --- | | `repeated-call` | The same tool was called with the same arguments 3 or more times in one run (checked when the turn starts) | | `repeated-thinking` | The reasoning said the same sentence again, ignoring case and punctuation: at the 4th time, then the 8th, 16th, ... | | `long-reasoning` | The reasoning passed 8000 characters, then 16000, ... (only the ones without an answer yet are worrying) | | `truncated` | The LLM stopped with `finish_reason: length`: it ran out of tokens, typically in a reasoning loop | These are heuristics for where to look, not verdicts, and nothing is stopped: the server's timeout only counts *silence*, so a model that reasons forever, out loud, is never cut off by it. The log has no text of the conversation. To read what the model actually thought, set `CHAT_TRACE_FILE` (say `data/chat-trace.jsonl`, which git ignores): each turn's entries are appended there as JSON lines, with the full `reasoning`, `reply` and (in `turn-start`) the `input` messages of the run, the tool results the model was reacting to. It holds what the user typed, so treat it like the database. ```bash # the runs that got stuck, and how jq -c 'select((.flags // []) | length > 0) | {run, step, flags, thinking_chars: ."thinking-chars"}' data/chat-trace.jsonl # what the model said in one turn jq -r 'select(.req == "9c1e4a07" and .event == "turn-end") | .reasoning' data/chat-trace.jsonl ``` ### Browser MCP tools (WebMCP) The page also offers tools of its own to your MCP client (Claude), through [WebMCP](https://webmcp.dev): a small library that adds a widget to the page and lets the page register tools with it. They are the browser-side twin of the [server's MCP tools](#mcp-server): those work on your data on the server, these work *the page*, doing what you could do with the mouse and keyboard, so you see it happen. A tool that changes something opens the tab it acts on first (the todo tools open Todos, the game tools open Tic-tac-toe, the calculator tools open Calculator). They are in `ui/src/tbd/ui/webmcp.cljs`, registered with `registerTool` on a `WebMCP` instance (the `@jason.today/webmcp` npm package): | Tool | What it does | |------|--------------| | `ping`, `whoami` | Health check; who is logged in on the page, if anyone | | `navigate` | Opens a page (`tic-tac-toe`, `calculator`, `todos` or `ai-setup`) as a click on its tab would; `todos` needs a login, like its tab. The chatbot is then offered the new page's tools | | `get_calculator` | What the calculator form holds: its two fields and the result it shows | | `add` | Types `x` and `y` into the calculator, presses Add, and returns the sum the page shows | | `list_todos` | The todos the page shows (login required, like the todo list itself) | | `get_todo` | Reads todos by id (`todos`: a list of `{id}`) | | `create_todo` | Types each title into the new-todo field and presses Add (`todos`: a list of `{title}`), returns the new todos | | `set_todo_done` | Ticks or unticks the checkboxes of several todos in one call (`todos`: a list of `{id, done}`), so a bulk change is one tool call | | `delete_todo` | Presses the Delete button of several todos (`todos`: a list of `{id}`) | The todo tools take their work as a list, so a batch ("untick all of them") is one tool call: the chatbot is capped in the tool calls it may make in a turn. A batch is checked in full before its first change is made, and then made one todo at a time; if one fails part way, the error says how many were done. Each tool dispatches the same re-frame events the views do and waits for the result to show up in the app-db, so what the agent sees is what the page shows, server errors included. There is no `update_todo` with a title as on the server, because the page can't edit one. Next to the tools the page registers **prompts** and **resources** (`prompts` and `resources` in `webmcp.cljs`), so a client's lists of them are not empty. They carry the same prompt engineering as the chatbot, from `shared/tbd/agent/prompt.cljc` (a `.cljc`, on the classpath of both the server and the UI build): act instead of offering, chain the calls, take obvious defaults, report in a sentence, and play tic-tac-toe by the turn. The WebMCP bridge sends no server-level instructions, so this is how Claude or Codex learns them: | Prompt | What it is | |--------|-----------| | `drive_the_app` | The instructions, then the `task` you gave it (Claude Code lists it as a slash command). Without a task, the agent asks what you want | | `play_tic_tac_toe` | Plays a game with you, one move on its own turn, as `side` (`x` or `o`, default `o`) | | Resource | What it is | |----------|-----------| | `webmcp://app/guide` | The instructions, as Markdown: read this first | | `webmcp://app/state` | JSON: the page that is open, the pages (which need a login) and who is logged in | The bridge prefixes their names with the page's channel, like the tools'. A client does not read a resource on its own: mention it (`@`) or ask the agent to read the guide. To connect (the page's "Browser tools (WebMCP)" section says the same): 1. Add the WebMCP server to your MCP client, once: `npx -y @jason.today/webmcp@latest --config claude` (restart Claude Desktop afterwards). 2. Ask the client to *make a webmcp token*. 3. Click the blue square in the bottom right corner of the page, paste the token, and press Connect. The token works once, and the page has no field for it: the widget is where it goes. 4. The tools show up in the client, prefixed with the page's host. ## Development Every task is a bash script in `script/`. Start an nREPL server (with CIDER middleware) on the port in `.nrepl-port`, or `3000` if that file doesn't exist: ```bash script/repl ``` ### Tests | Task | What it does | |------|--------------| | `script/unit-test` | Runs the Clojure tests in `test/` | | `script/ui-test` | Runs the ClojureScript tests in `ui/test/` under Node, in a [jsdom](https://github.com/jsdom/jsdom) window (needs Node and a JDK) | | `script/integration-test` | Builds the app image, starts it on a random port, runs the [integration suite](integration-test/README.md) against it, then tears it down | | `script/test` | UI tests, unit tests, then integration tests (stops at the first failure) | | `script/docker-unit-test` | Same as `script/unit-test`, but inside the app image (Docker only) | | `script/docker-ui-test` | Same as `script/ui-test`, but inside a Node + JDK image (Docker only) | The UI tests render the real app in jsdom against a fake server and drive it like a user (type, submit, click), next to plain tests of the re-frame events and subscriptions, the game rules and the WebMCP tools (`ui/test/tbd/ui/`; the shared fakes are in `test_util.cljs`). `ui/test/dom-setup.cjs` gives them a `window` and the real WebMCP library, and fails a run that hangs, rejects a promise nobody handles, or ends without a result (a test that never calls `done` would otherwise pass or stall silently). `npm test` in `ui/` preloads it, so use that (or the scripts) rather than running the build's output directly. ### Coverage Line coverage of the Clojure code via [Cloverage](https://github.com/cloverage/cloverage), and of the UI via [c8](https://github.com/bcoe/c8): | Task | Output | |------|--------| | `script/unit-coverage` | `target/coverage/unit/` | | `script/integration-coverage` | `target/coverage/integration/` | | `script/ui-coverage` | `target/coverage/ui/` (V8 coverage of the compiled UI, mapped back to `ui/src/**/*.cljs` through the source maps) | | `script/coverage` | Runs all three, then writes the merged Clojure report to `target/coverage/merged/` (the UI's stays separate) | | `script/docker-unit-coverage`, `script/docker-ui-coverage`, `script/docker-coverage-report` | Dockerized versions of the unit run, the UI run and the merge step, so `script/docker-ui-coverage`, `script/docker-unit-coverage`, `script/integration-coverage`, `script/docker-coverage-report` is `script/coverage` with no local Clojure, Node or Babashka (this is what CI runs) | Each report directory has an `index.html` and an `lcov.info`. The UI's numbers are approximate where one ClojureScript form compiles to several JavaScript blocks (a `case` or `cond`, say): a line can show as uncovered when only part of the form ran. See [integration-test/README.md](integration-test/README.md) for how integration coverage is collected. ## Project layout ``` src/tbd/ core.clj Router, Ring app, and server startup /routes.clj Each route group (health, math, ...) owns its route subtree /core.clj Business logic for the group, with no HTTP concerns /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 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 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) test/ Unit tests integration-test/ Dockerized integration tests and coverage harness script/ Bash entry points (server, ui-build, ui-test, test, coverage, repl, ...) and coverage helpers ``` To add a new group, create `tbd..routes` with a `routes` fn that returns a reitit route vector, then add it to the router in `src/tbd/core.clj`. To make a route admin-only, put `:admin? true` in its route data (admin allowlist), or `:login? true` (any logged-in user; the user is passed to the handler as `:user`). A group with storage keeps it in its own `tbd..db`, apart from its business logic in `core.clj`, with a `migrate!` fn for its tables in the app's one database; add it to the `sqlite/open` call in `make-router`.