Skip to content
in buildBuild phases 0–14 of 16 done · phase 15: only the demo video is left

DevSprint Copilot: a tool-loop agent with human-approved writes and a bidirectional MCP story

In-progress DevOps copilot over a mock sprint board: Vue 3 SPA, Hono on Cloudflare Workers, an AI SDK v6 agent with human-approved writes, an MCP server and client. Phases 0–14 of 16; demo live on Groq and Gemini.

stack:
Vue 3.5 · Vite 8 (Rolldown) · Hono · Cloudflare Workers · AI SDK v6 (ToolLoopAgent) · MCP over Streamable HTTP · TypeScript (strict) · pnpm workspaces · Durable Objects · Groq + Gemini · Langfuse (OTLP)
published:
DevSprint Copilot system architecture A Vue 3.5 single-page app with a streaming chat UI calls a Hono Cloudflare Worker, which is the only place model keys live. The Worker runs an AI SDK v6 ToolLoopAgent against Groq gpt-oss-120b, falling back to Gemini Flash; the deployed demo runs these live models. The agent's tool calls pass through a human-in-the-loop approval gate on every write tool; approvals are given in-stream from the SPA and the server re-validates before execution. An MCP client over Streamable HTTP connects to mcp-issues, the one MCP server built, which is also exposed publicly; mcp-deploys and mcp-analytics were never built and are drawn dashed. External MCP hosts such as Claude Desktop can connect to the public mcp-issues server directly. Vue 3.5 SPA streaming chat UI Hono · CF Worker model keys live here only ToolLoopAgent AI SDK v6 (pinned) Groq · Gemini Flash demo: live model tool calls HITL approval gate every write tool approve in-stream server re-validates before execution MCP client Streamable HTTP mcp-issues also public server mcp-deploys not built mcp-analytics not built external MCP hosts e.g. Claude Desktop
DevSprint Copilot system architecture · scroll sideways to see all of it

The problem

I wanted a project that demonstrates agent architecture (tool design, human-in-the-loop approval, streaming UI, MCP interop) to reviewers who have seen a hundred thin chat-wrapper demos. DevSprint Copilot is a project/DevOps copilot that operates a mock software-delivery workspace through chat: ask how the sprint is going, close issues, draft release notes, kick off a simulated deploy, and watch the effects land on a board pane next to the conversation.

The workspace is deliberately fake and deliberately deterministic: one project, two sprints, 13 issues on a five-column board, four users, five deployments, all seeded from fixtures with a frozen demo clock (WORKSPACE_NOW = 2026-07-01T09:00:00Z). That is not a shortcut; it is the eval strategy. When the seed never changes, “DS-11 is blocked” and “8 of 31 points done” are exact assertions, and the same six scripted scenarios can serve as acceptance spec, trajectory-eval suite, and Playwright E2E spec without reinterpretation.

Architecture

The front end is a Vue 3.5 SPA on Vite 8 (Rolldown) with two panes: workspace board left, chat right. The backend is Hono on Cloudflare Workers running the AI SDK v6 ToolLoopAgent entirely server-side. Model keys live only in Worker env vars, and the browser never talks to a model provider. Shared types, route constants, and the fixture set live in a workspace package both sides import.

The agent gets exactly eleven tools, inventoried up front with Zod input schemas and a shared result envelope ({ ok: true, data } | { ok: false, error }). Tools are classified by risk: read/query tools run unattended; rich-data tools feed generative-UI cards (burndown chart, release-notes diff); every write is gated behind an in-stream approval card, one approval per write; an Approve all press answers a group of cards through the same per-card path (ADR-0023). The click the user makes is a UX affordance; the Worker re-validates the tool input and re-evaluates the approval policy before executing, because client-supplied message history could otherwise forge an approval that never happened.

The MCP story was staged deliberately (see the decision log below). Phases 3–5 ran all ten tools as plain function calls inside the API Worker; phase 6 moved the four issue tools (five since assign_issue joined) behind a custom MCP server on Workers, publicly reachable over Streamable HTTP for external hosts like Claude Desktop, and the backend consumes it as an MCP client itself. One system sits on both sides of the protocol, and that is the differentiator. The transport-agnostic result envelope is what made the move a refactor rather than a rewrite. The diagram shows the built topology: the deploys and analytics servers, drawn dashed, were never built, so those six tools stay plain function calls in the API Worker.

Evaluation design

No number is published yet; every row in the metrics table above is pending, and I would rather show an empty table than an invented one. What exists is the harness, and an honest account of what it can and cannot prove.

The eval unit is the trajectory, not the answer. A plausible paragraph about sprint health is worthless if the agent never called get_sprint_status; the interesting failures are wrong tool order, skipped gates, and hallucinated arguments, so each of the six demo scenarios ships with verbatim eval assertions. For scenario S-A, that means tools called in exactly the order get_sprint_status → list_issues → diff_release_notes → create_release_notes, and create_release_notes never executes without a prior approval response. Those blocks were transcribed 1:1 into eval checks in phase 10 and into Playwright expectations in phase 13, without reinterpretation; each browser test asserts the DOM, the UI stream and the Durable Object (ADR-0018).

The harness landed in phase 10 alongside observability: OTLP traces exported to Langfuse without the OpenTelemetry SDK. It runs every scenario against a scripted mock model, deterministic and free, which proves the application honours each trajectory, not that a live model produces it. It ran in CI until 2026-10-21 and runs locally with pnpm ci:local since. Live-model runs work since 2026-10-22: before the demo switched to a live model, the six scenarios ran against Groq and Gemini, and that surfaced six problems the mock had hidden, from both configured models having been retired to a tool description that contradicted its scenario (ADR-0021). The evals now have two tiers: every model must meet each scenario’s meaning, and the mock must also produce its exact sentences. A live run is a sample on free tiers, a quality signal on demand and never a CI gate. The deterministic seed keeps all of this cheap: no LLM-as-judge is needed when the expected tool calls and workspace states are exact.

Cost and latency as design inputs

The product brief sets a hard budget: running cost of at most 5 EUR/month, target 0. That is a design constraint, not a measurement. It shaped the model routing directly. Groq is the primary, now openai/gpt-oss-120b, whose free tier allows 8,000 tokens a minute while one agent turn spends about 2,000; gemini-3.8-flash is a fallback on a different provider; and a global daily token cap acts as a kill-switch that degrades the app into a designed “demo budget reached” state instead of surfacing provider errors mid-conversation. Since 2026-10-22 the deployed demo answers with these live models. The free tiers are its capacity limit: a busy minute falls through to Gemini, and Gemini itself sometimes answers “high demand”.

Latency is handled structurally rather than numerically at this stage: everything streams. The phase-2 milestone was token-by-token streaming from Worker to SPA before any model was involved, so the streaming path is proven plumbing by the time real tokens arrive. On the live site Lighthouse scored 100 in all four categories, on mobile and desktop (roadmap, phase 14); accessibility is gated by axe across five UI states in both themes instead, which found three WCAG AA violations the 100 had hidden (ADR-0017). The phase-10 tracing that would produce time-to-first-token and per-turn cost is built; the deployed Worker has had a live model since 2026-10-22 but sends no traces yet, so neither number is recorded.

What’s built, what’s next

Built today: phases 0–14 of the 16-phase roadmap. The agent runs: the AI SDK v6 ToolLoopAgent chains the eleven tools within a six-step budget, the six read and rich-data tools unattended and every write behind an in-stream approval that is signed, single-use and re-validated on the server, with an Approve all press for a group of cards and an Undo on each applied board write. The five issue tools are served by a public MCP server over Streamable HTTP, which the app consumes through its own MCP client. A long-running deploy tool streams its progress and can be cancelled mid-run; a daily alarm resets the board; every step can be traced to Langfuse over OTLP (no keys are set on the deployed demo); and two spend controls guard the demo, a per-IP rate limit and a daily token cap, next to a stated prompt-injection posture. Since 2026-10-21 three Workers run on Cloudflare: the SPA, the API and the public MCP server. Since 2026-10-22 the demo answers with Groq openai/gpt-oss-120b, falling back to Gemini gemini-3.8-flash.

What is not proven: the six demo scenarios’ assertions run as trajectory evals against a scripted mock model, and the browser tests drive the same mock. That proves the application honours each trajectory. The live-model runs are samples: in the production configuration 7 of 9 cases passed on 2026-10-22, each failure a provider refusing a request, so no pass rate is published. Model spend and latency on the live demo are not recorded. Next: the demo video, the one part of phase 15 left.

Key decisions

  1. 01 Pin the AI SDK to the v6 line, not v7

    ai@6.0.277 via the official ai-v6 dist-tag, exact-pinned in the pnpm catalog with an explicit do-not-bump note (npm latest is v7).

    v6 is the mature line whose ToolLoopAgent and per-tool needsApproval gate this build depends on; with an exact pin, upgrades become deliberate events, not lockfile accidents.

    rejected: ai@7 (npm latest) · loose semver ranges

  2. 02 Staged MCP adoption

    Plain AI SDK function calling for phases 3–5, then refactor the same 10 tools behind 2–3 Workers-hosted MCP servers (Streamable HTTP) in phase 6, with the issues server publicly exposed.

    The transport-agnostic { ok, data | error } tool envelope makes the move a refactor, not a rewrite. MCP transport/spec churn is isolated to one phase instead of blocking the first working agent.

    Status note (2026-10-22): the staging happened, smaller than planned. One server was built, mcp-issues, with five issue tools since assign_issue joined on 2026-10-25; the deploys and analytics servers were not, so those six tools stay plain function calls in the API Worker.

    rejected: MCP-first from phase 3 · plain function calling forever · a third-party MCP framework as the only abstraction

  3. 03 HITL approval as policy, separate from SDK mechanism

    Every write tool always requires in-stream human approval, no batching (N implied writes = N gates), and the server re-validates input and policy before executing.

    Client-supplied message history can forge an approval that never happened, so the client is a renderer, not an authority; stating the policy apart from the SDK mechanism kept the ADR stable through implementation.

    The mechanism (ADR-0010) is per-tool needsApproval, the only approval gate in ai@6.0.277, plus a signing secret against forged approvals and a single-use ledger against replayed ones: the SDK's signature alone let one captured approval run the same write three times. Since 2026-10-25 an Approve all press answers a group's open cards through the same per-card path, so each write is still approved and recorded on its own, and an applied board write can be undone from its card while the board has not moved on (ADR-0023).

    rejected: client-side-only gating · trusting the SDK's approval signature alone · auto-approving low-risk writes

  4. 04 Model routing: Groq primary, Gemini Flash fallback, global token cap

    Groq openai/gpt-oss-120b as primary, Gemini gemini-3.8-flash as a second leg on a different provider, and a daily token-cap kill-switch that degrades into a designed 'demo budget reached' state.

    The ≤ 5 EUR/month budget rules out providers without a durable free tier, and a public demo needs a hard stop so one crawler can’t leave it broken mid-conversation.

    Status note (2026-10-22): the models first chosen, llama-3.3-70b-versatile and gemini-2.5-flash, had been retired by their providers, so the current two were picked on live scenario runs, and the demo has run them since (MODEL_PROVIDER=auto, ADR-0021). A Groq call goes to Gemini only after a rate-limit, quota or server error.

    rejected: Anthropic or OpenAI direct · Workers AI as primary · single provider with no fallback

  5. 05 Deterministic seed with a frozen demo clock

    One seeded workspace with WORKSPACE_NOW fixed at 2026-07-01T09:00:00Z; all stats compute against the demo clock, never Date.now().

    Determinism is what makes trajectory evals and Playwright specs exactly assertable: 'DS-11 is blocked' is a testable fact, not a flaky one. A real integration adds auth and rate-limit surface without demonstrating more agent architecture.

    rejected: live-generated mock data · integrating a real issue tracker

  6. 06 One catalog with exact pins, one lint config

    The toolchain and the shared dependencies, the AI SDK and MCP packages among them, are exact-pinned in the pnpm-workspace default catalog, with do-not-bump notes on the AI SDK and MCP lines; one ESLint config serves the whole repo. App-only UI libraries keep caret ranges in their own package.json.

    A solo monorepo drifts fastest through version skew; one source of truth makes every upgrade a reviewed, greppable diff, and readable history is itself part of the portfolio.

    rejected: semver ranges for the shared toolchain and AI packages · per-package lint configs

  7. 07 The workspace in one Durable Object

    The whole workspace lives in a Durable Object defined in the API Worker and bound cross-script from the MCP Worker; no D1 next to it, and the MCP server stays stateless.

    Separate Workers share no memory, and scenario S-F needs a write made in an external MCP host to appear on the SPA's board. Measured locally, a D1 database shared by two wrangler dev processes threw an internal error in 8 of 50 concurrent writes; a cross-script Durable Object lost nothing under the same load, and it serialises the next issue key, so two clients cannot both claim DS-14.

    rejected: D1, pulled forward from phase 9 · mounting the MCP routes on the API Worker · the MCP server owning the state

  8. 08 A rate limit on the public MCP endpoint, with a private way in for the agent

    Limit the public /mcp endpoint to 30 requests a minute per client IP; the in-app agent reaches the same handler through a named Internal entrypoint that only a service binding in the account can call.

    The public server runs create_issue ungated, because the MCP host owns that prompt, so a loop could fill the shared board to its 512 KB cap in seconds. A binding request carries no client IP, so exempting it would work by accident; a shared secret guards what the platform already scopes; and the byte cap already bounds size, while the problem was speed.

    ADR-0020, verified locally: 30 requests answered, then 429, while chat turns still reached their tools over MCP.

    rejected: exempting binding traffic because it carries no client IP · a shared secret header from the API · an issue-count cap

Evaluation results

Evaluation results
MetricValueStatus
Trajectory-eval pass rate (6 demo scenarios)n/apending

The six scenarios’ assertions run against a scripted mock model (in CI until 2026-10-21, locally since), which proves the app honours each trajectory. Live-model runs on 2026-10-22 (ADR-0021) are samples: the production configuration passed 7 of 9 eval cases, every scenario passed in at least one run, and each failure was a provider refusing a request. No rate is published from samples.

Gated-write integrity (no write executes without prior approval)n/apending

Asserted in every scenario’s trajectory eval and enforced on the server: approvals are signed, single-use and re-validated before a write runs. The live samples check it too; no rate is published from them.

Tool-selection quality on the 11-tool setn/apending

On live scenario runs (ADR-0021), Groq gpt-oss-120b beat Groq's other tool-calling model, qwen3.8-27b, which was slower, hit rate limits sooner and dropped writes, so gpt-oss-120b is the primary. No score is published from those samples.

Scenario E2E suite against a live modeln/apending

Built in phase 13: one Playwright test per demo scenario, asserting the DOM, the UI stream and the Durable Object (ADR-0018). They passed in CI until 2026-10-21 and run locally since, against the scripted model, so they prove the app, not a live model.

Cost & latency

Cost & latency
MetricValueStatus
Monthly run costn/apending

Designed for ≤ 5 EUR/month (target 0) via free tiers. Since 2026-10-22 the demo answers with live models on free tiers; no month's cost has been recorded.

Daily token spend vs. global capn/apending

The cap and per-IP rate limits are built (phase 11). The demo has spent real tokens since 2026-10-22, about 2,000 per agent turn by ADR-0021, but its daily spend against the cap has not been recorded.

Time-to-first-token per chat turnn/apending

The agent loop streams, and tracing to Langfuse is built, but the deployed demo sends no traces (no Langfuse keys are set there), so no time-to-first-token is recorded.

Safety & failure modes

Risks

  • Destructive writes: the agent mutates the shared demo workspace (issue status, release notes, deployments).
  • Prompt injection via workspace content: ticket titles and comments flow into the context and could steer the agent’s tool calls.
  • Model-key exfiltration if provider credentials ever reach the client.
  • Quota exhaustion or runaway token spend on a publicly reachable demo.

Mitigations

  • Every write tool is HITL-gated in-stream with no batching, and the Worker re-validates input and approval policy before executing. Client approval is a UX affordance; the server is the authority.
  • Typed Zod tool contracts with allowlisted, enumerated arguments; every write the in-app agent makes still crosses a human approval gate (the public MCP server runs create_issue ungated, because the MCP host owns that prompt, ADR-0016; ADR-0020 adds a limit of 30 requests a minute per IP on that endpoint, verified locally); trajectory evals act as regression gates against steered tool paths (against the scripted model; in CI until 2026-10-21, locally since).
  • Keys live only in Worker env vars; the browser never talks to a model provider.
  • Groq-to-Gemini fallback, a per-IP rate limit of 60 chat requests a minute and a global daily cap of 100,000 tokens that degrades into a designed 'demo budget reached' state.