Serve agents over HTTP
HTTP serving lets browser apps, mobile apps, other services, and agents written in another language use the same conversational runtime. The wire protocol has a primary turn endpoint and optional companion endpoints for snapshots and aborts. Over it, a client streams model output, custom state, artifacts, and interrupts through one agent interface, then continues the next turn with a session ID, snapshot ID, or client-managed state.
Route helpers
Section titled “Route helpers”The experimental route helpers live in github.com/firebase/genkit/go/genkit/exp. They return route descriptors that you can mount on http.ServeMux or any standard Go router.
package main
import ( "context" "log" "net/http"
"github.com/firebase/genkit/go/ai" aix "github.com/firebase/genkit/go/ai/exp" "github.com/firebase/genkit/go/ai/exp/localstore" "github.com/firebase/genkit/go/genkit" genkitx "github.com/firebase/genkit/go/genkit/exp" "github.com/firebase/genkit/go/plugins/googlegenai")
func main() { ctx := context.Background()
// Initialize Genkit with experimental support enabled for Agents. g := genkit.Init(ctx, genkit.WithPlugins(&googlegenai.GoogleAI{}), genkit.WithExperimental(), )
store, err := localstore.NewFileSessionStore[any]("./.genkit/snapshots/chat") if err != nil { log.Fatalf("open session store: %v", err) }
genkitx.DefineAgent(g, "chat", aix.InlinePrompt{ ai.WithModelName("googleai/gemini-flash-latest"), ai.WithSystem("You are a helpful travel assistant."), }, aix.WithSessionStore(store), )
mux := http.NewServeMux() for _, route := range genkitx.AllAgentRoutes(g) { mux.HandleFunc(route.Pattern(), route.Handler()) }
log.Println("listening on http://localhost:8080") log.Fatal(http.ListenAndServe(":8080", mux))}Use genkitx.AgentRoutes(agent) to mount one agent, or genkitx.AllAgentRoutes(g) to mount every registered agent. The fields on Route are exported, so a router other than http.ServeMux can mount the same layout: read Method and Path, and serve Action with genkit.Handler.
A client-managed agent gets the turn route alone. A store-backed agent adds getSnapshot and waitForSnapshot, and abort when its store supports status subscriptions, which is what an abort needs to reach the running work.
Handler options
Section titled “Handler options”Route.Handler takes the same options as genkit.Handler:
func (r Route) Handler(opts ...genkit.HandlerOption) http.HandlerFuncPass genkit.WithContextProviders to derive request context server-side, or genkit.WithStreamManager to make a streamed turn reconnectable:
mux.Handle(route.Pattern(), route.Handler(genkit.WithStreamManager(sm)))The turn response then carries an X-Genkit-Stream-Id header. A client that reconnects with ?stream=true and that header resubscribes to the in-flight stream instead of starting a new turn. See Durable streaming.
Browser clients and CORS
Section titled “Browser clients and CORS”When browser clients connect across origins, apply standard CORS middleware (such as github.com/rs/cors or your framework’s CORS middleware) to your HTTP router to allow POST and OPTIONS requests along with headers such as Content-Type and Authorization. When the frontend and the Go process share an origin, or the frontend proxies to it, no CORS wrapper is needed.
Route layout
Section titled “Route layout”Agent routes follow a consistent layout across backend frameworks:
POST /agents/{name}(or/api/{name}): Always exists. It handles one turn per request. Add?stream=truefor server-sent events.POST /agents/{name}/getSnapshot(or/api/{name}/getSnapshot): Exists when the agent has a session store. Use it to read bysnapshotIdor by latestsessionId.POST /agents/{name}/abort(or/api/{name}/abort): Exists when the agent has a store that supports status subscriptions. Use it to cancel detached background work.
Go mounts one more companion, POST /agents/{name}/waitForSnapshot, on every store-backed agent. It takes the same body as getSnapshot and answers with the same shaped snapshot, but only once the row has settled, so a client follows a detached run in one request instead of a polling loop. Both read routes accept "metadataOnly": true in data to return the status, finish reason, parent, and timestamps without the conversation.
Every route uses the standard Genkit HTTP envelope. The turn input goes in data, and session initialization goes in the optional init. Omit init to start a fresh conversation, or include sessionId, snapshotId, or state to continue one.
curl -X POST http://localhost:8080/agents/chat \ -H 'content-type: application/json' \ -d '{"data":{"message":{"role":"user","content":[{"text":"Weather in Tokyo?"}]}}}'Response envelope
Section titled “Response envelope”A non-streaming turn answers with the reflection API’s result envelope wrapping one AgentOutput:
{ "result": { "message": { "role": "model", "content": [{ "text": "Tokyo is 18°C and clear." }] }, "sessionId": "6b1c2f3e-6a1e-4c1b-9c74-1f2b8d0a5e11", "snapshotId": "9f2a41d6-0c2b-4f0e-8a7a-3f9d1c6b2e40", "finishReason": "stop" }}The fields of AgentOutput:
| Field | Meaning |
|---|---|
message | The last model response message of the conversation. |
sessionId | The conversation’s ID. The framework assigns it on the first invocation and it is stable across resumes. |
snapshotId | The most recent turn-end snapshot. Present only when the agent has a session store. |
state | The full SessionState. Present only for client-managed agents, those with no store. |
artifacts | Artifacts produced during the session. |
finishReason | Why the invocation finished: stop, length, blocked, interrupted, other, unknown, aborted, detached, or failed. |
error | Structured failure details. Present only when finishReason is failed. |
Copy result.sessionId into init.sessionId on the next request, and result.snapshotId into the getSnapshot and abort bodies. When streaming, read the session ID from the terminal data: {"result": ...} frame; chunk frames do not carry it. For a client-managed agent it also rides at result.state.sessionId.
Continue a server-managed conversation with the ID the previous response returned:
curl -X POST http://localhost:8080/agents/chat \ -H 'content-type: application/json' \ -d '{"data":{"message":{"role":"user","content":[{"text":"What about Paris?"}]}},"init":{"sessionId":"6b1c2f3e-6a1e-4c1b-9c74-1f2b8d0a5e11"}}'Continue a client-managed conversation by sending back the state object from the previous response. An agent defined without a session store returns responses that carry the whole SessionState: sessionId, messages, custom, and artifacts.
curl -X POST http://localhost:8080/agents/statelessChat \ -H 'content-type: application/json' \ -d '{"data":{"message":{"role":"user","content":[{"text":"What is my name?"}]}},"init":{"state":{"sessionId":"6b1c2f3e-6a1e-4c1b-9c74-1f2b8d0a5e11","messages":[{"role":"user","content":[{"text":"My name is Alex."}]},{"role":"model","content":[{"text":"Nice to meet you, Alex."}]}],"custom":{}}}}'Streaming
Section titled “Streaming”Stream a turn as server-sent events:
curl -N -X POST 'http://localhost:8080/agents/chat?stream=true' \ -H 'content-type: application/json' \ -d '{"data":{"message":{"role":"user","content":[{"text":"Suggest three day trips from Tokyo."}]}},"init":{}}'Sending Accept: text/event-stream turns streaming on as well, with or without ?stream=true.
Every frame is one data: line. There are three shapes:
data: {"message":{"modelChunk":{"role":"model","content":[{"text":"Nikko"}]}}}
data: {"message":{"modelChunk":{"role":"model","content":[{"text":" is a good day trip."}]}}}
data: {"message":{"turnEnd":{"finishReason":"stop","snapshotId":"9f2a41d6-0c2b-4f0e-8a7a-3f9d1c6b2e40"}}}
data: {"result":{"message":{"role":"model","content":[{"text":"Nikko is a good day trip."}]},"sessionId":"6b1c2f3e-6a1e-4c1b-9c74-1f2b8d0a5e11","snapshotId":"9f2a41d6-0c2b-4f0e-8a7a-3f9d1c6b2e40","finishReason":"stop"}}{"message": <chunk>}repeats for each streamed chunk. The inner object represents the stream chunk, carrying fields such asmodelChunk,customPatch,artifact, andturnEnd.{"result": <AgentOutput>}is the terminal frame and the only one that carriessessionId.{"error": {"status": ..., "message": ...}}replaces the terminal frame when the stream fails.
Reading custom state from a raw client
Section titled “Reading custom state from a raw client”Custom state reaches a raw HTTP client as customPatch on a chunk, an RFC 6902 JSON Patch rooted at the custom document. Its pointers have no /custom prefix, so a field named agentStatus is at /agentStatus:
data: {"message":{"customPatch":[{"op":"replace","path":"","value":{"agentStatus":"searching"}}]}}
data: {"message":{"customPatch":[{"op":"replace","path":"/agentStatus","value":"summarizing"}]}}The first patch of each turn is a whole-document replace at the root pointer "", which re-bases a client that joined mid-conversation. Apply later patches incrementally to keep a local copy live. In Go, use aix.ApplyPatch; browser clients can use standard RFC 6902 libraries like fast-json-patch. The Vercel AI SDK transport described below performs this reassembly automatically.
Interrupts over HTTP
Section titled “Interrupts over HTTP”When an interruptible tool pauses, the turn returns HTTP 200: finishReason is interrupted and the interrupt rides as a tool-request part on the message content, carrying the tool’s payload under metadata.interrupt.
{ "result": { "finishReason": "interrupted", "message": { "role": "model", "content": [ { "toolRequest": { "name": "transferMoney", "ref": "call_1", "input": { "toAccount": "alice", "amount": 200 } }, "metadata": { "interrupt": { "reason": "large_amount", "amount": 200 } } } ] }, "sessionId": "6b1c2f3e-6a1e-4c1b-9c74-1f2b8d0a5e11", "snapshotId": "9f2a41d6-0c2b-4f0e-8a7a-3f9d1c6b2e40" }}Answer it on the next request through data.resume, which takes respond, restart, or both:
curl -X POST http://localhost:8080/agents/banker \ -H 'content-type: application/json' \ -d '{"data":{"resume":{"restart":[{"toolRequest":{"name":"transferMoney","ref":"call_1","input":{"toAccount":"alice","amount":200}},"metadata":{"resumed":{"approved":true}}}]}},"init":{"sessionId":"6b1c2f3e-6a1e-4c1b-9c74-1f2b8d0a5e11"}}'respond supplies the tool’s output directly; restart re-runs the tool with a typed answer. The runtime validates the payload: name and ref must match a pending tool request in the most recent model response, and a restarted request must carry its original input unmodified. See Agent interrupts.
Snapshot and abort companions
Section titled “Snapshot and abort companions”Read a snapshot:
curl -X POST http://localhost:8080/agents/chat/getSnapshot \ -H 'content-type: application/json' \ -d '{"data":{"snapshotId":"9f2a41d6-0c2b-4f0e-8a7a-3f9d1c6b2e40"}}'Abort detached work:
curl -X POST http://localhost:8080/agents/chat/abort \ -H 'content-type: application/json' \ -d '{"data":{"snapshotId":"9f2a41d6-0c2b-4f0e-8a7a-3f9d1c6b2e40"}}'Both take the snapshotId a previous turn returned at result.snapshotId. See Background execution.
Block until a detached run settles, or read where it stands without its conversation:
curl -X POST http://localhost:8080/agents/chat/waitForSnapshot \ -H 'content-type: application/json' \ -d '{"data":{"snapshotId":"9f2a41d6-0c2b-4f0e-8a7a-3f9d1c6b2e40"}}'curl -X POST http://localhost:8080/agents/chat/getSnapshot \ -H 'content-type: application/json' \ -d '{"data":{"snapshotId":"9f2a41d6-0c2b-4f0e-8a7a-3f9d1c6b2e40","metadataOnly":true}}'An abort answers aborting for a run that was still going; the settled row is one waitForSnapshot away.
Failure tiers
Section titled “Failure tiers”Failures arrive at two different levels:
- Turn-level failures: A failed turn returns 200 with
finishReason: "failed"along with structured error details and the last known good state, allowing the client to retry or handle the failure without losing conversation state. - Request-level failures: A malformed init payload (such as an unknown
snapshotIdor sendingstateto a store-backed agent) fails immediately with an HTTP 4xx error before running the turn.
Connect a client
Section titled “Connect a client”The client is independent of the backend language. Point it at the primary turn URL your server exposes, the route you mounted above, and it speaks the same wire protocol either way. The snapshot and abort companion URLs follow your server’s route layout.
Go ships no prebuilt HTTP agent client. Server code should hold the agent value and call RunText, Run, or Connect in process; see Run and stream agents. Another Go service can POST the envelope shown above directly.
For a browser or mobile frontend in front of a Go backend, use the JavaScript client. It is in the genkit npm package:
npm i genkitimport { remoteAgent } from 'genkit/beta/client';
const agent = remoteAgent({ url: 'http://localhost:8080/agents/chat' });
const chat = agent.chat();const res = await chat.send('Weather in Tokyo?');The JavaScript version of this page documents the client in full.
Client behavior
Section titled “Client behavior”When using server-managed state, make sure the same auth and tenant checks apply to the primary, snapshot, and abort endpoints. Snapshot IDs are powerful because they can reveal conversation history. Treat them like conversation-scoped credentials, and verify that the caller is allowed to read or abort the requested session.
For client-managed agents, the remote client sends the full state back to the primary endpoint. That keeps the server stateless, but request size grows with conversation history and artifacts. Prefer server-managed routes for long-running chat experiences or background tasks.
Vercel AI SDK UI and AI Elements
Section titled “Vercel AI SDK UI and AI Elements”@genkit-ai/vercel-ai connects an agent to the Vercel AI SDK UI library — the framework chat bindings such as useChat, not the broader Vercel AI SDK. GenkitChatTransport implements AI SDK UI’s framework-agnostic ChatTransport, so it works with any of the bindings, including React, Vue, Svelte, and Angular. The transport speaks the same wire protocol over the agent route, so a JavaScript frontend can call a Genkit agent HTTP backend in any supported language.
With the agent behind an AI SDK UI binding, you drive it from the SDK’s chat primitives instead of wiring up remoteAgent() yourself. In React, you can also assemble the interface from Vercel’s AI Elements components, which are built on the AI SDK UI primitives.
This path is server-managed only. The transport sends the chat id to the agent as its sessionId, and the agent persists each turn in its session store, so there is no client-side snapshot bookkeeping. The id must be a bare UUID.
Install it alongside the AI SDK UI binding you use:
npm i @genkit-ai/vercel-aiPoint the transport at the same agent route you serve for turns. The examples below use /api/weatherAgent; replace it with the path your own server mounts, shown in the routing section above. A same-origin path works when the frontend is served from the backend process or proxied to it. A cross-origin URL such as http://localhost:8080/agents/weatherAgent needs CORS headers on the agent route.
These examples use React and Angular; the Vue and Svelte bindings accept the same GenkitChatTransport.
import { useMemo, useState } from 'react';import { useChat } from '@ai-sdk/react';import { GenkitChatTransport } from '@genkit-ai/vercel-ai/client';
function Chat() { // The chat id is sent to the agent as its sessionId, so it must be a UUID. const chatId = useMemo(() => crypto.randomUUID(), []); const [input, setInput] = useState('');
const { messages, sendMessage, status } = useChat({ id: chatId, transport: new GenkitChatTransport({ url: '/api/weatherAgent' }), });
return ( <> {messages.map((message) => ( <div key={message.id}> <strong>{message.role}: </strong> {/* A UIMessage is a list of typed parts; render the text ones. */} {message.parts.map((part, i) => part.type === 'text' ? <span key={i}>{part.text}</span> : null, )} </div> ))}
<form onSubmit={(e) => { e.preventDefault(); if (!input.trim()) return; sendMessage({ text: input }); setInput(''); }} > <input value={input} onChange={(e) => setInput(e.target.value)} /> <button disabled={status !== 'ready'}>Send</button> </form> </> );}import { Component, signal } from '@angular/core';import { FormsModule } from '@angular/forms';import { Chat } from '@ai-sdk/angular';import { GenkitChatTransport } from '@genkit-ai/vercel-ai/client';
@Component({ selector: 'app-chat', imports: [FormsModule], template: ` @for (message of chat.messages; track message.id) { <div> <strong>{{ message.role }}: </strong> <!-- A UIMessage is a list of typed parts; render the text ones. --> @for (part of message.parts; track $index) { @if (part.type === 'text') { <span>{{ part.text }}</span> } } </div> }
<form (submit)="$event.preventDefault(); send()"> <input [ngModel]="input()" (ngModelChange)="input.set($event)" name="input" /> <button [disabled]="chat.status !== 'ready'">Send</button> </form> `,})export class ChatComponent { input = signal('');
// The chat id is sent to the agent as its sessionId, so it must be a UUID. // `Chat` is signal-backed, so `chat.messages` and `chat.status` are reactive // in the template. chat = new Chat({ id: crypto.randomUUID(), transport: new GenkitChatTransport({ url: '/api/weatherAgent' }), });
send() { if (!this.input().trim()) return; this.chat.sendMessage({ text: this.input() }); this.input.set(''); }}Neither binding manages input state, so you hold it yourself and pass the text to sendMessage({ text }). Each message is a UIMessage whose parts array holds typed segments (text, tool calls, and so on); the loop above renders the text parts. status is ready when the agent is idle.
Reading custom state and tool calls
Section titled “Reading custom state and tool calls”AI SDK UI streams structured data alongside the chat as data parts, delivered through the binding’s onData callback rather than added to messages. This is the SDK’s standard channel for anything that is not chat text, and the transport reuses it to carry the agent’s custom state: each time the agent updates its session state, it emits a transient data-custom part with the full, current state. Because it is transient and never lands on a message, a UI that only renders messages never sees it — read it in onData. Both useChat(options) and new Chat(options) take onData in the same options object as id and transport:
onData: (part) => { if (part.type === 'data-custom') { // part.data is the agent's full, current custom state. renderCustomState(part.data); }},Tool calls arrive as tool-<name> parts on the assistant message, each advancing through a state lifecycle: input-streaming → input-available → output-available (or output-error). Scan the latest assistant message’s parts to drive per-tool progress indicators.
GenkitChatTransport takes url and an optional headers object or function for rotating auth tokens. To resume an earlier conversation, convert a snapshot’s messages with messagesFromSnapshot() and pass them to your chat binding’s messages option (for example, useChat({ id, messages })). When the user answers an interrupt through the SDK’s addToolResult, the transport returns the resolved tool output to the agent as a resume payload automatically.
On the server, this is the standard agent route shown above, backed by a session store so each sessionId keeps its own conversation; no extra wiring is required.