DEV Community

Cover image for Genkit Agents: Why the Whole Is Greater Than the Sum of Its Parts
middleware.mind
middleware.mind

Posted on

Genkit Agents: Why the Whole Is Greater Than the Sum of Its Parts

Most "AI agent frameworks" hand you a big Agent class with fifty options and hope for the best. Genkit takes a different bet: agents are built from the same small primitives you already use (tools, middleware, interrupts, sessions, streaming), so advanced behavior composes in layers instead of being bolted on.

The result is a framework where human-in-the-loop approval, a sandboxed coding assistant, and multi-agent delegation aren't separate features. They're the same handful of ideas snapping together. This is the "greater than the sum of its parts" thing, and it's genuinely fun to use.


TL;DR

If you only read the bold bits:

  • A working agent is ~10 lines. ai.defineAgent({ system, tools, store }) + agent.chat().sendStream().
  • You choose where state lives. Pass a store for server-managed sessions, or omit it for client-managed state (the client round-trips the state blob). Same agent, same API.
  • Interrupts turn a tool call into a pause button. Define a "tool" with no handler; the run stops, hands control to you, and resumes exactly where it left off. This is how you do human-in-the-loop.
  • Middleware adds real capabilities one line at a time. A use: [...] array stacks a filesystem, a skills library, approval gates, retries, and sub-agent delegation.
  • The pieces reinforce each other. The approval-gate middleware is built on the same interrupt machinery you'd use by hand. Sub-agents are just agents-as-tools. Artifacts flow between them.
  • The same agent runs on the server and in the browser. expressHandler(agent) on the backend, remoteAgent({ url }) on the client, with identical streaming and interrupt-resume semantics.

Everything below expands on those points, and every snippet is lifted from the real samples in the Genkit repo. Skip to whatever interests you.


1. The 10-line agent

Start with the smallest possible thing. A tool, an agent, and a chat:

import { z } from 'genkit';
import { FileSessionStore } from 'genkit/beta';
import { ai } from './genkit.js';

export const getWeather = ai.defineTool(
  {
    name: 'getWeather',
    description: 'Get the current weather for a given location.',
    inputSchema: z.object({ location: z.string() }),
    outputSchema: z.object({ weather: z.string() }),
  },
  async (input) => ({ weather: `Sunny in ${input.location}`, temperature: '71F' })
);

export const weatherAgent = ai.defineAgent({
  name: 'weatherAgent',
  system: 'You are an assistant helping with weather information. Use the getWeather tool.',
  tools: [getWeather],
  store: new FileSessionStore('./.snapshots'),
});
Enter fullscreen mode Exit fullscreen mode

Talking to it feels like normal Node.js. Start a chat, send a message, stream the chunks:

const chat = weatherAgent.chat();
const turn = chat.sendStream('What is the weather like in London?');

for await (const chunk of turn.stream) {
  process.stdout.write(chunk.text ?? '');
}

const res = await turn.response;
Enter fullscreen mode Exit fullscreen mode

Multi-turn is free. A single chat carries state across turns via that store, so a follow-up is just another send:

await chat.sendStream('What about Paris?').response;
await chat.sendStream('now say that in French').response;
Enter fullscreen mode Exit fullscreen mode

One line controls where state lives. That store gives you server-managed state: the session lives on the server and the chat tracks it for you. Drop store and the agent is client-managed - the client owns a state blob and echoes it back each turn. Same agent, same chat API; you just choose who holds the memory (and remoteAgent handles the round-trip for you in the browser).

Server-managed vs client-managed state

Omit store and the agent is stateless server-side. Resume from the client-held blob, then hand the updated one back:

// No `store` → the client owns the session state.
export const weatherAgentStateless = ai.defineAgent({
  name: 'weatherAgentStateless',
  system: 'You are a helpful weather assistant...',
  tools: [getWeather],
});

// Resume from the client-held blob, then return the updated one.
const chat = weatherAgentStateless.chat(state ? { state } : undefined);
const res = await chat.sendStream('What is the weather in Tokyo?').response;
return { state: res.raw.state, message: res.message };
Enter fullscreen mode Exit fullscreen mode

Why care? Server-managed state is simplest for a trusted backend. Client-managed state means the server holds nothing between turns - handy for stateless/serverless deployments, easy horizontal scaling, or when the client (not your server) should own the conversation. Tool-calling and multi-turn work identically either way.

That's the whole base layer. Now watch how much you get by layering on top of it.


2. Interrupts: a tool call that never runs (on purpose)

Here's the first idea that pays off everywhere else.

An interrupt is a tool with no handler. When the model "calls" it, the agent doesn't execute anything. It pauses, surfaces the request to you, and waits. You supply the result, and the run resumes from the exact point it stopped. Tool calls become control flow.

This is the cleanest human-in-the-loop primitive I've seen. Consider a banking agent that must get approval before moving money:

export const userApproval = ai.defineInterrupt({
  name: 'userApproval',
  description: 'Ask the user for approval before proceeding with a sensitive action.',
  inputSchema: z.object({
    action: z.string(),
    details: z.string(),
  }),
  outputSchema: z.object({
    approved: z.boolean(),
    feedback: z.string().optional(),
  }),
});

export const bankingAgent = ai.defineAgent({
  name: 'bankingAgent',
  system:
    'You are a banking assistant. If the user wants to transfer money, ALWAYS use ' +
    'the userApproval interrupt to confirm details before calling transferMoney.',
  tools: [userApproval, transferMoney],
  store,
});
Enter fullscreen mode Exit fullscreen mode

The magic is on the response. When the agent pauses, res.interrupts is populated, and each interrupt hands you respond/restart builders to continue the run:

const chat = bankingAgent.chat();
let res = await chat.sendStream('Transfer $500 to my savings account.').response;

// Did the agent pause for approval?
const approval = res.interrupts.find((i) => i.name === 'userApproval');

if (approval) {
  // Resume the SAME chat. It tracked the snapshot for us.
  const resume = chat.resumeStream({
    respond: [approval.respond({ approved: true, feedback: 'Looks good' })],
  });
  res = await resume.response;
}
Enter fullscreen mode Exit fullscreen mode

Why "a tool that never executes" is a superpower

Because the pause is just data (res.interrupts) and the resume is just another call (chat.resumeStream), the mechanism doesn't care where the human is. It works the same whether you're auto-approving in a test, prompting on a CLI, or rendering a dialog in a browser. There's no special "human-in-the-loop mode" to configure. It's the same request/response you already understand, paused in the middle.

There are two resume styles:

  • respond supplies the tool's output directly (the tool body never runs). Perfect for approvals and "ask the user a question."
  • restart re-issues the original tool call so it does execute this time, optionally tagged with metadata like { toolApproved: true }. Perfect for "run this only after I approve it."

3. Middleware: capabilities as one-liners

Every agent takes a use: [...] array. Each entry is a piece of middleware that weaves tools and behavior into the agent's generate loop. This is where a bare chatbot becomes something powerful, without you writing the plumbing.

The @genkit-ai/middleware package ships factories like filesystem, skills, toolApproval, retry, artifacts, and agents. Here's a full-featured coding assistant assembled almost entirely out of them:

import { filesystem, retry, skills, toolApproval } from '@genkit-ai/middleware';

export const codingAgent = ai.defineAgent({
  name: 'codingAgent',
  system: `You are an expert AI coding assistant working in a sandboxed workspace...`,
  tools: [runShell, askUser], // custom tools
  use: [
    // Reads + shell are auto-approved; writes require user confirmation.
    toolApproval({
      approved: ['list_files', 'read_file', 'use_skill', 'run_shell', 'ask_user'],
    }),
    // Adds list_files, read_file, write_file, search_and_replace - scoped to a dir.
    filesystem({ rootDirectory: WORKSPACE_DIR, allowWriteAccess: true }),
    // Loads coding conventions on demand via a use_skill tool.
    skills({ skillPaths: [SKILLS_DIR] }),
    // Automatic retry on transient model errors.
    retry(),
  ],
  store,
  maxTurns: 30,
});
Enter fullscreen mode Exit fullscreen mode

Read that use array top to bottom and you've read the agent's entire capability set: it can approve tools, edit a sandboxed filesystem, load skills, and retry on failure. No glue code. Each line is a real, production-grade feature.

The order matters (and the comment tells you why)

In the sample, toolApproval is deliberately placed before filesystem so that the interrupt thrown by a pending approval propagates up cleanly instead of being swallowed by the filesystem middleware's error handling. Middleware composes like an onion, so ordering is a real (documented) knob, not an accident.


4. The payoff: the pieces reinforce each other

This is the section that makes the "sum of its parts" claim concrete. Watch the primitives from sections 2 and 3 combine.

Approval gates are interrupts

That toolApproval middleware in the coding agent? It doesn't invent a new approval system. When a write is attempted, it emits the exact same kind of interrupt you saw in the banking demo. So the client handling code is identical: check res.interrupts, then resumeStream. You learned it once; it works everywhere.

A tool can pause itself with ctx.interrupt()

The coding agent's run_shell tool has an AI-powered safety gate. A cheap, fast model classifies each command, and risky ones trigger an interrupt from inside the tool handler:

async (input, ctx) => {
  const isApproved = ctx.metadata?.resumed?.toolApproved === true;

  if (!isApproved) {
    const safetyCheck = await ai.generate({
      model: liteModel,
      prompt: `Evaluate this shell command for safety: "${input.command}" ...`,
      output: { schema: z.object({ verdict: z.enum(['safe', 'risky']), reason: z.string() }) },
    });

    if (safetyCheck.output!.verdict === 'risky') {
      // Pause and hand the decision to the human.
      ctx.interrupt({ command: input.command, reason: safetyCheck.output!.reason });
    }
  }

  // ...actually run the command...
}
Enter fullscreen mode Exit fullscreen mode

Look at what just happened: a model, a tool, and an interrupt combined into a self-guarding action. Safe commands run instantly; risky ones ask permission. That's three primitives producing behavior none of them has alone.

Sub-agents are just agents-as-tools

The agents middleware lets one agent delegate to specialized others. You define sub-agents exactly like any agent, then list them in an orchestrator's use:

export const orchestratorAgent = ai.defineAgent({
  name: 'orchestratorAgent',
  system: 'Analyze the request and delegate to the right sub-agent, then synthesize a final answer.',
  use: [
    agents({
      agents: [
        'researcher', // description auto-discovered from the agent's registry metadata
        { name: 'coder', description: 'Writes, debugs, and explains code...' },
      ],
      maxDelegations: 5,   // guard rail against runaway loops
      historyLength: 4,    // how much context to forward to sub-agents
      artifactStrategy: 'session', // merge sub-agent outputs into the parent session
    }),
    artifacts({ readonly: true }), // orchestrator can READ what sub-agents produced
    retry(),
  ],
});
Enter fullscreen mode Exit fullscreen mode

The middleware injects one delegation tool per sub-agent (delegate_to_researcher, delegate_to_coder). When the orchestrator calls one, the middleware runs that sub-agent and returns its answer as the tool result. And because sub-agents write artifacts that merge into the shared session, the researcher's findings are available to the coder and the orchestrator. Again: composition, not a bespoke "multi-agent engine."


5. The same agent, backend and browser

Here's the part that ties the bow. Everything above works identically from a web client, because the client speaks the same protocol.

Server: expose the agent as an HTTP endpoint.

import { expressHandler } from '@genkit-ai/express';

app.post('/api/bankingAgent', expressHandler(bankingAgent));
Enter fullscreen mode Exit fullscreen mode

Browser: connect with remoteAgent and you get the same chat(), sendStream(), res.interrupts, and resumeStream() you used server-side:

import { remoteAgent, type AgentChat } from 'genkit/beta/client';

const agent = remoteAgent({ url: '/api/bankingAgent' });
const chat = agent.chat();

// Stream a turn
const turn = chat.sendStream('Transfer $500 to my savings account.');
for await (const chunk of turn.stream) {
  setStreamingText(chunk.accumulatedText);
}
const res = await turn.response;

// Same interrupt handling as the server - render a dialog, then resume
const pending = res.interrupts.find((i) => i.name === 'userApproval');
if (pending) {
  // ...user clicks Approve in a React dialog...
  const respond = pending.respond({ approved: true, feedback });
  await chat.resumeStream({ respond: [respond] }).response;
}
Enter fullscreen mode Exit fullscreen mode

That's the whole point in one snippet. The interrupt/resume pattern you learned for a test flow is exactly what powers an approval dialog in production. No new concepts. The server tracks the session snapshot; the client just says "resume with this answer."


Verdict

Plenty of frameworks can call an LLM and run a tool. What makes Genkit's agent layer stand out is that its features are the same few ideas seen from different angles:

  • Interrupts give you human-in-the-loop.
  • Middleware gives you filesystems, skills, retries, and delegation.
  • The approval middleware is interrupts.
  • Sub-agents are tools.
  • The browser client is the server API.

Learn the primitives once and everything else is recombination. That's what "greater than the sum of its parts" actually feels like in code: a small vocabulary that keeps buying you bigger things.

Bonus for mobile devs: the same concepts exist in the Genkit Dart SDK, so a Flutter app can share the mental model end to end.

Want the full picture? The official Genkit agents guide walks through all of this in depth.

Top comments (0)