DEV Community

Cover image for Human-in-the-Loop Agents in Pure Dart: Genkit, MCP and an Approval Gate on Cloud Run

Human-in-the-Loop Agents in Pure Dart: Genkit, MCP and an Approval Gate on Cloud Run

1. The problem: an agent that can spend money

Most agent demos stop at "the model called a tool and it worked". Real back offices have tools you do not want a model to run on its own.

I built a dispatch assistant for a small, made-up field service company. A dispatcher types "What is the status of WO-1043?" or "Order a 3 kW heating element for WO-1044." and the agent answers. It has three tools:

  • lookup_work_order reads the site, asset, issue, technician and slot. βœ… Runs on its own.
  • schedule_visit books a technician for a window. βœ… Runs on its own.
  • order_part orders a spare part, and that costs money. ❌ Waits for a person.

And I wanted the whole thing in Dart: the agent server, the tool server and the Flutter client, sharing one set of types. No Python sidecar, no Node service in the middle.

2. Architecture: the agent only knows the back office through MCP

Architecture: HTTP into the dispatchAgent Genkit flow, which calls Gemini; the toolApproval middleware holds order_part for a person; tools come through the MCP client from the work-orders MCP server (lookup_work_order and schedule_visit run on their own, order_part costs money)

Four pieces, all Dart:

  • The back office as an MCP server (genkit_mcp, Streamable HTTP on loopback). Any MCP client can use it: this agent, an IDE, another agent.
  • The agent as one Genkit flow (genkit, genkit_google_genai) that connects to that MCP server and gets its tools from it. It never imports the back office directly.
  • An approval gate (genkit_middleware's toolApproval) in front of every tool that costs money.
  • The HTTP surface (genkit_shelf): one POST /dispatchAgent plus a health check.

Why MCP for a back office that lives in the same process? Because the boundary is the point. The day the work orders move to a real system, only the MCP server changes. The agent, its prompt and its tests do not.

Exposing the back office is a few lines per tool:

ai.defineTool<PartOrder, String>(
  name: orderPartToolName,
  description:
      'Orders a spare part for one work order, which costs money, and marks the order waiting for parts. '
      'Returns the updated work order as JSON.',
  inputSchema: PartOrder.$schema,
  fn: (input, _) async =>
      .response(describeWorkOrder(workOrders.orderPart(input.orderId, part: input.part), input.orderId)),
);
final server = createMcpServer(ai, McpServerOptions(name: 'work-orders', version: '0.2.0'));
final transport = await StreamableHttpServerTransport.bind(address: InternetAddress.loopbackIPv4, port: port);
await server.start(transport);
Enter fullscreen mode Exit fullscreen mode

Note the tool description says "which costs money". The model reads that too. πŸ˜‰

3. One schema, three runtimes

The types are schemantic classes in one file with no dart:io import:

@Schema()
abstract class $DispatchAnswer {
  String get answer;

  /// The work order ids the agent looked up to answer, so the client can link them.
  List<String> get orderIds;

  /// Set when the agent stopped before a tool that needs a person's OK; send it back with `approved`.
  String? get approvalId;
}
Enter fullscreen mode Exit fullscreen mode

The same DispatchAnswer is the model's structured output schema, the flow's HTTP response, and the type the Flutter client deserialises through package:genkit/client.dart. Because the file avoids dart:io, the client builds for web, iOS and macOS from the same import. Change a field once, build_runner regenerates, and the compiler tells you everywhere it matters. That is the full-stack Dart pitch in one file. πŸ’™

4. The approval gate: interrupt, hold, restart

toolApproval takes the list of tools that may run without asking. Everything else interrupts generation:

// The MCP client lists `work-orders/<name>`, but the model calls the bare `<name>`; allow both spellings.
final runWithoutApproval = [
  for (final tool in tools)
    if (tool != orderPart) ...[tool.name, bareToolName(tool.name)],
];

final response = await ai.generate(
  model: model,
  messages: messages,
  tools: tools,
  maxTurns: maxAgentTurns,
  outputSchema: DispatchAnswer.$schema,
  use: [toolApproval(approved: runWithoutApproval)],
  interruptRestart: restart,
  interruptRespond: respond,
);
Enter fullscreen mode Exit fullscreen mode

When the model asks for order_part, the response comes back with finishReason == FinishReason.interrupted. The flow stores the conversation so far plus the interrupted tool request under an approvalId and answers the client right away:

{"result": {"answer": "Ordering \"Heating element 3 kW\" for WO-1044 needs your approval.",
            "orderIds": ["WO-1044"], "approvalId": "approval-1"}}
Enter fullscreen mode Exit fullscreen mode

The Flutter client shows Approve and Decline. The next call sends the approvalId back with approved:

return input.approved ?? false
    ? await run(
        messages: held.messages,
        restart: [held.interrupt.restart({'tool-approved': true})],
      )
    : await run(messages: held.messages, respond: [InterruptResponse(held.interrupt, declinedToolOutput)]);
Enter fullscreen mode Exit fullscreen mode
  • Approve restarts the held call: the tool runs, the model answers from its real result.
  • Decline never runs the tool. The model gets "Declined by the dispatcher. Nothing was ordered." as the tool output and explains that to the user in its own words.
  • Each approvalId answers once. It is removed from the pending map on first use, so a double tap cannot order two heating elements. πŸ™ˆ

Held approvals live in memory. That is a deliberate choice for a demo on Cloud Run with --max-instances 1: one instance, one map. A restart forgets them, and anything beyond a demo wants them in Firestore or a database.

5. The bug: approve, restart… "Tool order_part not found" 🫠

This is where it got interesting. Lookups worked. Bookings worked. The approval prompt appeared. I approved, and the restart failed with Tool order_part not found.

The cause, in Genkit Dart 1.0.1 (packages/genkit/lib/src/ai/generate.dart):

  1. MCP tools are registered under a namespaced name: work-orders/order_part.
  2. The model is shown the short name, order_part, and echoes it back in its tool request.
  3. The normal tool loop resolves that through a short-name map (toolMap), so it finds the tool.
  4. The resume path (interruptRestart) calls the tool executor without that map, falls back to a registry lookup for order_part, misses, and throws.

So any namespaced tool (every MCP tool) works until you put an approval in front of it. I reproduced it with a fake model and two tests, one with a plain tool name and one with work-orders/order_part. Plain passes, namespaced fails. Then I patched a local copy of genkit 1.0.1 through dependency_overrides to pass the map on the restart path, and both tests went green.

It is filed upstream with the repro and the root cause as genkit-ai/genkit-dart#657, and the fix is open as genkit-ai/genkit-dart#658: the restart path now resolves tools the same way the loop does, with a regression test for a namespaced tool. My first open source PR to Genkit Dart. πŸ₯³

Until it is merged and released, the workaround is a local tool under the bare name that forwards to the MCP one:

// Genkit 1.0.1 restarts an approved call by the bare name it sent the model and skips the short-name map
// the normal tool loop uses, so `work-orders/order_part` is never found. A local `order_part` that forwards
// to the MCP tool is.
final orderPart = ai.defineTool<PartOrder, String>(
  name: orderPartToolName,
  description: mcpOrderPart.description ?? 'Orders a spare part for one work order.',
  inputSchema: PartOrder.$schema,
  fn: (input, _) async => .response('${(await mcpOrderPart.call(input.toJson())).output}'),
);
final tools = [...mcpTools.where((tool) => tool != mcpOrderPart), orderPart];
Enter fullscreen mode Exit fullscreen mode

The MCP server stays the single owner of the logic; the adapter is a name and a forward. When #658 ships in a release, it is one deletion.

6. Testing an agent without a key

The agent tests drive the full loop with a fake model defined through ai.defineModel: the fake asks for an MCP tool, the tool answers over real HTTP from the loopback MCP server, and the flow returns the structured answer. They cover a part order that is held, approved, declined, and answered twice.

dart test
00:00 +14: All tests passed!
Enter fullscreen mode Exit fullscreen mode

14 tests, no Gemini call, no API key, no network beyond loopback. That is what makes the approval logic safe to refactor: the expensive, non-deterministic part (the model) is the only thing faked.

7. Shipping: a 21 MB native binary on scratch

Dart compiles the server to one native executable, so the runtime image needs no Dart SDK at all:

FROM dart:3.12 AS build
WORKDIR /app
COPY pubspec.yaml pubspec.lock ./
RUN dart pub get
COPY . .
RUN dart pub get --offline && dart compile exe bin/server.dart -o /app/server

FROM scratch
COPY --from=build /runtime/ /
COPY --from=build /app/server /app/server
EXPOSE 8080
ENTRYPOINT ["/app/server"]
Enter fullscreen mode Exit fullscreen mode

The result is 20.7 MB on disk (6 MB compressed content), for an agent server, an MCP server and an HTTP router in one process.

Deploy is one command, with the key from Secret Manager and the service private:

gcloud run deploy genkit-dispatch-agent --source . --region europe-west3 \
  --no-allow-unauthenticated --max-instances 1 --set-secrets GEMINI_API_KEY=gemini-api-key:latest
Enter fullscreen mode Exit fullscreen mode

--no-allow-unauthenticated means only callers with roles/run.invoker get through; everyone else gets a 403. For local development against the private service, gcloud run services proxy serves it on localhost with your own credentials.

One Cloud Run gotcha I had to fix after the first deploy: don't name your health check /healthz. Cloud Run's front end reserves paths ending in z and never forwards them to your container. /health works. 🀌

8. Key findings

  • A full agent stack in one language works today. Genkit Dart 1.0.1, genkit_mcp, genkit_middleware and genkit_shelf cover flow, tools, MCP client and server, approval and HTTP. The Flutter client shares the types.
  • Put the approval in the middleware, not in the prompt. A prompt can be talked out of "always ask first". A toolApproval interrupt cannot.
  • Approvals need ids that answer once. Otherwise a retry or a double tap runs a paid action twice.
  • MCP plus interrupts is new territory. The first approval I wired up hit a framework bug, a sign that few people have combined them yet. Worth testing the resume path, not only the happy path.
  • Native Dart on scratch makes Cloud Run cheap and fast to ship: about 21 MB, no runtime to patch.

9. Next steps

  • Persist pending approvals (Firestore) so they survive instance restarts and allow more than one instance.
  • Send an identity token from the Flutter client so it can call the private service directly, without the proxy.
  • Delete the adapter once #658 ships in a Genkit Dart release.

Are you building agents in Dart yet, or still reaching for Python on the server? I would love to hear what your approval story looks like. πŸ‘‡

Top comments (4)

Collapse
 
nullandvoid_ profile image
Jyanthi •

Really interesting approach! Putting a human approval gate before actions that cost money is a smart way to make AI agents safer and more reliable. Great work keeping the entire stack in Dart! πŸ‘

Collapse
 
paullovelace profile image
Paul Lovelace •

The detail I liked most is the tool description literally saying "which costs money" β€” the model reads that too, so the boundary lives in the prose as well as the middleware. One schema across three runtimes is the Dart pitch that actually lands for me. What happens to a held approval if the Cloud Run instance restarts before the dispatcher taps Approve β€” does the approvalId survive, or is that the known sharp edge?

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

approval gate before the money tool is the part that matters. without it you're just letting the agent spend and hoping.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.