Every AI agent built on a plain request-response model hits the same ceiling: it runs only as long as the client stays connected. If the request times out or the user closes the tab, the work stops with it, and whatever the agent completed may be lost.
The traditional way around this was to build the escape hatch yourself. You stood up a job queue, moved the work into a separate worker, added a polling channel so the client could check progress, and built a persistence layer so a returning client could find the result. You also had to hand-roll approval gates for tools that should not run unattended and decide how much to trust whatever the client sent back.
Genkit’s Agents API, released by the Firebase team, moves much of that infrastructure into the framework. You can start a long-running turn, detach it from the client connection, and let the agent keep working on the server while Genkit writes progress to a persistent snapshot. A client can reconnect later by snapshot ID and continue from the saved state. You still need a server process that stays alive while the detached work runs, but you no longer have to build a separate queue or worker simply to outlive the browser connection.
In this article, you’ll build a real multi-turn agent with Genkit from scratch. You’ll define the agent on the server, back its sessions with Firestore, operate it from a TypeScript frontend through remoteAgent() using the same interface as a local agent, run detached turns for long-running work, and gate risky tool calls behind a human approval step.
For this guide, the backend is a Node.js and TypeScript project. Genkit needs a current Node runtime, and these examples use Node 22.
Start with an empty directory and initialize the project:
mkdir genkit-agents && cd genkit-agents pnpm init
You need three packages to start. genkit provides the core framework and beta agent API, @genkit-ai/google-genai provides the Gemini model plugin, and @genkit-ai/express mounts the agent as HTTP endpoints later:
pnpm add genkit @genkit-ai/google-genai @genkit-ai/express
Add TypeScript and a runner so you can execute .ts files directly during development. If you’re evaluating package managers, see this comparison of pnpm vs. npm to understand the tradeoffs:
pnpm add -D typescript tsx @types/node
The model plugin authenticates with a Gemini API key. Create one at aistudio.google.com, then export it into the shell where you run the agent:
export GEMINI_API_KEY="your-key-here"
Next, wire the Gemini plugin into a Genkit instance and export it for the rest of the app:
// src/genkit.ts
import { genkit } from "genkit/beta";
import { googleAI } from "@genkit-ai/google-genai";
export const ai = genkit({
plugins: [googleAI()],
});
Importing from genkit/beta instead of genkit exposes the agent methods on the instance. That single ai instance is what every later section builds on.
An agent in Genkit is a named configuration bound to a model and a system instruction. Register one with defineAgent, which returns an object containing the conversation logic:
// src/assistant.ts
import { ai } from "./genkit";
import { googleAI } from "@genkit-ai/google-genai";
export const assistant = ai.defineAgent({
name: "assistant",
system:
"You are a concise, helpful assistant. Answer in one or two sentences.",
model: googleAI.model("gemini-flash-latest"),
});
The name identifies the agent in traces and, later, in the HTTP layer. The system instruction defines its standing behavior across turns. The model reference comes from the Gemini plugin, and googleAI.model("gemini-flash-latest") resolves to the fully qualified ID googleai/gemini-flash-latest, which the runtime routes to the model.
The assistant object does not send anything on its own. Start a conversation by calling chat(), which opens a session and returns a chat handle. That handle exposes the methods that communicate with the model.
The simplest is send, which submits one turn and resolves with the complete response:
// run-send.ts
import { assistant } from "./src/assistant";
const chat = assistant.chat();
const res = await chat.send(
"Name three uses for a detached background task. Be brief."
);
console.log(res.text);
You get the finished response in one piece:
Three common uses for a detached background task are sending automated email notifications, processing or resizing large media files, and running periodic system maintenance or cleanup scripts.
You await send because it waits for the full response before resolving. When you want output as it is produced, use sendStream instead.
One detail matters at the call site: you do not await sendStream. It returns its result object synchronously, while the stream it exposes is async-iterable:
// run-stream.ts
import { assistant } from "./src/assistant";
const chat = assistant.chat();
const { stream } = chat.sendStream(
"Count from 1 to 5 with a word for each number."
);
for await (const chunk of stream) {
process.stdout.write(chunk.text ?? "");
}
The chunks arrive in sequence and assemble into the full reply:
One, two, three, four, five.
Both methods run on the same chat handle, and that handle represents the conversation. Each chat() call opens a session, and every turn you send through it becomes part of the history the next turn can see.
A second send on the same handle therefore already knows what the first turn established:
// run-multiturn.ts
import { assistant } from "./src/assistant";
const chat = assistant.chat();
const r1 = await chat.send("My name is Ada. Remember it.");
console.log("turn 1:", r1.text);
const r2 = await chat.send("What is my name?");
console.log("turn 2:", r2.text);
The second turn recalls what the first stored, with no manual message passing:
turn 1: Hello Ada! I will definitely remember your name. turn 2: Your name is Ada.
That memory lives in the session. So far, however, the session exists only in the running process. Restart the script and Ada is gone because nothing has written the conversation to durable storage.
That is fine for a local experiment. An agent that must survive a restart, deployment, or client reconnect hours later needs its session state to live outside the process.
The session that remembered Ada lived in process memory, so it disappeared when the process exited. A deployed agent needs durable session state, which Genkit provides through a configurable session store.
Adding a store switches the agent to server-managed state. The store owns the conversation, and clients refer to a session by ID. Without a store, the agent uses client-managed state instead.
For local development, Genkit ships FileSessionStore from genkit/beta:
// src/assistant.ts
import { ai } from "./genkit";
import { googleAI } from "@genkit-ai/google-genai";
import { FileSessionStore } from "genkit/beta";
export const assistant = ai.defineAgent({
name: "assistant",
system:
"You are a concise, helpful assistant. Answer in one or two sentences.",
model: googleAI.model("gemini-flash-latest"),
store: new FileSessionStore(".sessions"),
});
Now one process can create a session and print its ID, while a second process starts fresh and reconnects using only that ID:
// persist-write.ts
const chat = assistant.chat();
const res = await chat.send("My name is Ada. Remember it.");
console.log("session id:", chat.sessionId);
// persist-read.ts
const chat = assistant.chat({ sessionId: process.argv[2] });
const res = await chat.send("What is my name?");
console.log("recalled:", res.text);
The result:
recalled: Your name is Ada!
The second process never saw the first process’s in-memory conversation. It knew only the session ID, and the store supplied the rest.
On disk, each turn writes a snapshot, while a pointer file maps the session ID to its current snapshot:
.sessions/global/<snapshotId>.json .sessions/global/.pointers/<sessionId>.json
A session and a snapshot serve different purposes. The session is the conversation thread you reconnect to. A snapshot is a saved point along that thread. When you reconnect, the store resolves the session to its latest snapshot.
FileSessionStore is useful for proving the mechanics, but a single server’s local disk is not durable enough for production. Firestore provides the same SessionStore interface outside any one machine, so the switch is one import and one configuration change.
Install the Google Cloud package:
pnpm add @genkit-ai/google-cloud
Then replace the file store with FirestoreSessionStore:
// src/assistant.ts
import { ai } from "./genkit";
import { googleAI } from "@genkit-ai/google-genai";
import { FirestoreSessionStore } from "@genkit-ai/google-cloud/beta";
export const assistant = ai.defineAgent({
name: "assistant",
system:
"You are a concise, helpful assistant. Answer in one or two sentences.",
model: googleAI.model("gemini-flash-latest"),
store: new FirestoreSessionStore(),
});
The empty constructor connects to the (default) database and authenticates using a service account. Create one with the Cloud Datastore User role in the Google Cloud console, download its JSON key, and point the application at it:
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/key.json"
The write-and-read proof runs unchanged because the store interface is the same:
recalled: Your name is Ada.
The session now lives in Firestore across three collections: genkit-sessions for snapshots, genkit-sessions-pointers for the per-session pointer a reconnect reads, and genkit-sessions-shards for checkpoint state.
The store writes each turn as an incremental diff against a periodic checkpoint instead of rewriting the entire conversation. That keeps per-turn storage work bounded by the checkpoint interval as the session grows.
To operate the agent from a browser, expose it through HTTP endpoints. Genkit serves an agent through three routes: the turn itself, plus getSnapshot and abort endpoints that the client uses to inspect and cancel detached runs.
Mount each route with expressHandler:
// src/server.ts
import express from "express";
import cors from "cors";
import { expressHandler } from "@genkit-ai/express";
import { assistant } from "./assistant";
const app = express();
app.use(cors({ origin: "http://localhost:5173" }));
app.use(express.json());
app.post("/api/assistant", expressHandler(assistant));
app.post(
"/api/assistant/getSnapshot",
expressHandler(assistant.getSnapshotDataAction)
);
app.post(
"/api/assistant/abort",
expressHandler(assistant.abortAgentAction)
);
app.listen(3400, () => {
console.log("agent server on http://localhost:3400");
});
You need CORS middleware when the frontend and server run on separate origins during development. If both are served from the same origin in production, that extra configuration is unnecessary.
On the frontend, remoteAgent gives you the same AgentAPI as a local agent. The chat() call here is therefore the same one you used with the in-process agent earlier. If you’re building an AI chat interface, you may also want to explore building an AI chat app with the Vercel AI SDK and Cloudflare Workers AI for a comparable approach in a different stack:
// web/src/main.ts
import { remoteAgent } from "genkit/beta/client";
const assistant = remoteAgent({
url: "http://localhost:3400/api/assistant",
stateManagement: "server",
});
const chat = assistant.chat();
const res = await chat.send(text);
console.log(res.text);
The client derives the snapshot and abort URLs from the base url. Setting stateManagement to server tells it that the agent owns its state through Firestore, matching the server-side definition.
A message typed in the browser can now reach the agent, persist to Firestore, and render back in the page. It is the same agent and the same chat() interface, now operating over HTTP.
Some agent turns take real time. A research pass, a multi-step tool sequence, or a long generation can easily outlast the browser connection that started it.
That is where the request-response ceiling becomes expensive. If the client disconnects and the turn dies with it, the user loses completed work, you may already have paid for model usage, and a retry starts over.
Detached turns let the work outlive the client connection. You start the turn, receive a handle immediately, and allow the server to keep working after the client goes away.
First, give the agent a tool that takes long enough to make detaching useful:
// src/assistant.ts
import { z } from "genkit/beta";
const slowResearch = ai.defineTool(
{
name: "slowResearch",
description: "Performs a slow research task and returns findings.",
inputSchema: z.object({ topic: z.string() }),
outputSchema: z.string(),
},
async ({ topic }) => {
await new Promise((r) => setTimeout(r, 15000));
return `Findings on ${topic}: three key points assembled.`;
}
);
Detach a turn with chat.detach(). It returns a DetachedTask once the server accepts the work, and that task carries the snapshotId another client can use to reconnect.
Expose that through a route that returns the ID immediately:
// src/server.ts
app.post("/api/assistant/research", async (req, res) => {
const chat = assistant.chat();
const task = await chat.detach(`Research ${req.body.topic}.`);
res.json({ snapshotId: task.snapshotId });
});
Calling the route returns right away while the work continues on the server:
{
"snapshotId": "17202eec-56a0-4177-90ee-cd0f8de5d80a"
}
One implementation detail determines whether this works: the detached turn continues inside the server process that started it. It does not automatically move to a separate background worker.
That means the server process still has to remain alive until the work completes. A long-running HTTP server satisfies that requirement for this example. Detached execution protects the turn from the client connection ending; it does not make the work independent of the server process itself.
A separate client can reconnect using only the snapshot ID. Call getSnapshot to read the status, which can be pending, completed, aborted, failed, or expired.
Poll while the snapshot is pending. Once it reaches completed, load the conversation with loadChat and continue it:
// detach-resume.ts
import { assistant } from "./src/assistant";
const snapshotId = process.argv[2];
let snap = await assistant.getSnapshot(snapshotId);
console.log("status:", snap?.status);
while (snap && snap.status === "pending") {
await new Promise((r) => setTimeout(r, 2000));
snap = await assistant.getSnapshot(snapshotId);
console.log("status:", snap?.status);
}
const chat = await assistant.loadChat({ snapshotId });
const res = await chat.send("Summarize what you found in one line.");
console.log("result:", res.text);
The reconnecting process never saw the turn start. It only watched the snapshot move from pending to completed, then loaded the finished conversation and continued it:
status: pending status: completed result: A detached background task is an independent, non-blocking asynchronous operation that outlives its parent scope and runs without structured lifecycle or error-handling coupling.
Only completed snapshots can be resumed. Trying to load a pending snapshot is rejected, which is why the client waits for a terminal state before calling loadChat.
To cancel a run instead, DetachedTask exposes abort, while a reconnecting client can abort through the agent’s abort endpoint.
Some tool calls should not execute without review. Deleting records, moving money, or sending a message on someone’s behalf are all cases where you want a human decision before the action runs. This mirrors the broader challenge explored in human-in-the-loop AI, where determining who owns the decision is fundamental to safe automation.
Genkit models that approval gate as an interrupt.
An interrupt looks like a tool definition, but you create it with defineInterrupt and provide no implementation. The turn pauses when the model calls it because a human is responsible for deciding what happens next:
// src/assistant.ts
const deleteRecords = ai.defineInterrupt({
name: "deleteRecords",
description: "Deletes records matching a query. Requires human approval.",
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({ deleted: z.number() }),
});
Add the interrupt to the agent’s tools and steer the model toward it in the system instruction:
// src/assistant.ts
export const assistant = ai.defineAgent({
name: "assistant",
system:
"You are an admin assistant. When asked to delete records, call deleteRecords.",
model: googleAI.model("gemini-flash-latest"),
tools: [slowResearch, deleteRecords],
store: new FirestoreSessionStore(),
});
When the model decides to call deleteRecords, the turn pauses instead of executing anything. The pending call appears in res.interrupts:
// approve.ts
const chat = assistant.chat();
const res = await chat.send(
"Delete all records where status is 'archived'."
);
console.log(
"interrupts:",
res.interrupts.map((i) => ({
name: i.name,
input: i.input,
}))
);
The output shows the pending call:
interrupts: [
{
name: 'deleteRecords',
input: { query: "status = 'archived'" }
}
]
The turn is now paused. Nothing has been deleted because the interrupt has no implementation to run.
A human can inspect the pending call and its input, make a decision, and then resume the turn. To approve by supplying a result manually, call interrupt.respond() and pass the response into chat.resume() through the respond array:
// approve.ts
if (res.interrupts.length > 0) {
const gate = res.interrupts[0];
const resumed = await chat.resume({
respond: [gate.respond({ deleted: 42 })],
});
console.log("after approval:", resumed.text);
}
The model continues with the approved result:
after approval: I have successfully deleted all 42 records where the status was set to 'archived'.
If you want the original call re-issued instead of manually supplying its result, use interrupt.restart() in a restart array. In either case, the human decision occurs between the model requesting the action and the turn continuing.
Server-managed state also strengthens the approval boundary. The runtime validates the resume operation against the session’s recorded history, so a respond entry must match a tool call the model actually made in that session.
A client cannot invent an approval for a call that never existed because the server checks the resume against persisted state rather than trusting the client’s claim. The approval gate is therefore enforced by the session history, rather than existing only as a UI convention. For teams thinking about where these kinds of safeguards belong in a broader product, it’s worth reading about where AI should go in your UI to make approval flows feel natural to users.
You now have an agent that can survive several common failure points in request-response architectures. Its sessions persist in Firestore and reconnect by ID. Long-running turns can detach from the client connection and continue on a long-lived server process. Risky tool calls can pause until a human approves how the turn should proceed.
Those capabilities sit behind the same chat() interface used by the local agent, which keeps the application model relatively consistent as the system grows. If you’re curious how this compares to other agentic CLI approaches, see this benchmark of Claude Code and OpenCode on a heavy refactor for a sense of where agentic workflows stand today.
From here, you can give the agent more production tools, move the frontend into the framework of your choice, and deploy the server somewhere that can stay alive long enough for detached turns to finish. You can find a complete sample of the source files on GitHub.

Compare the top AI development tools and models of August 2026. View updated rankings, feature breakdowns, and find the best fit for you.

Learn how to build and deploy production-ready AI streaming applications in Nuxt using the Vercel AI SDK, Nuxt UI chat components, and Cloudflare Workers AI at the edge.

Learn how to build a streaming AI chat app in Nuxt using the Vercel AI SDK, Nuxt UI chat components, and Cloudflare Workers AI for edge inference.

Meta’s Astryx ships an MCP server and component manifest so agents query real APIs. We tested it against shadcn/ui with Claude Code across three real UI builds.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now