Skip to content

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.

Every Genkit agent is a bidirectional action. Serve the agent action itself for turns. Serve the snapshot companion only for server-managed agents, and serve the abort companion only when clients need to cancel detached work.

import { expressHandler } from '@genkit-ai/express';
import express from 'express';
const app = express();
app.post('/api/weatherAgent', expressHandler(weatherAgent));
app.post(
'/api/weatherAgent/getSnapshot',
expressHandler(weatherAgent.getSnapshotDataAction),
);
app.post(
'/api/weatherAgent/waitForSnapshot',
expressHandler(weatherAgent.waitForSnapshotAction),
);
app.post(
'/api/weatherAgent/abort',
expressHandler(weatherAgent.abortAgentAction),
);
app.listen(8080);

The primary endpoint handles normal and streaming turns. The snapshot endpoint reads by snapshotId or sessionId, and accepts metadataOnly: true to skip the conversation state. The wait endpoint takes the same body shape, but requires snapshotId and ignores metadataOnly. It holds until the snapshot settles, up to the agent’s maxSnapshotWaitMs (25 seconds by default), then answers with the snapshot as it stands. remoteAgent uses it for task.wait() and falls back to polling getSnapshot when it is not mounted. The abort endpoint takes { snapshotId }.

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"
"github.com/firebase/genkit/go/plugins/server"
)
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())
}
// server.Start returns nil after Ctrl+C or SIGTERM, once in-flight
// requests drain.
log.Println("listening on http://127.0.0.1:8080")
if err := server.Start(ctx, "127.0.0.1:8080", mux); err != nil {
log.Fatal(err)
}
}

Use genkitx.AgentRoutes(agent) to mount one agent, or genkitx.AllAgentRoutes(g) to mount every registered agent. AllAgentRoutes skips the agents that genkitx.DefineRemoteAgent registered, so a server never serves another service’s agent under its own credentials. 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.

Route.Handler takes the same options as genkit.Handler:

func (r Route) Handler(opts ...genkit.HandlerOption) http.HandlerFunc

Pass 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.

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.

Serve local agents over HTTP with GenkitRouter from package:genkit/io.dart. addAgent (from package:genkit/experimental_io.dart) mounts the turn route plus the companion routes the agent supports:

  • POST /<name>: runs a turn. Always mounted.
  • POST /<name>/getSnapshot: reads a snapshot. Mounted for agents with a session store.
  • POST /<name>/abort: aborts a detached turn. Mounted when the store can signal the running turn.
import 'package:genkit/experimental_io.dart';
import 'package:genkit/io.dart';
void main() async {
final genkit = GenkitRouter()
..addAgent(weatherAgent, path: '/api/weatherAgent');
await genkit.serve(port: 8080);
}

The turn endpoint processes both streaming and non-streaming requests. The snapshot endpoint reads the database by snapshotId or returns the latest leaf by sessionId. The abort endpoint takes the snapshotId and cancels running background tasks.

Pass a contextProvider to authorize requests. It applies to every route of the agent, so reading or aborting a snapshot is authorized the same way as running a turn:

Future<Map<String, dynamic>> bearerAuth(RequestData request) async {
final user = await verifyToken(request.headers['authorization']);
if (user == null) {
throw GenkitException('Unauthorized', status: StatusCode.unauthenticated);
}
return {'uid': user.id};
}
genkit.addAgent(weatherAgent, contextProvider: bearerAuth);

To serve agents next to your own Shelf routes, mount the router with asShelfHandler() from genkit_shelf:

import 'package:genkit/experimental_io.dart';
import 'package:genkit/io.dart';
import 'package:genkit_shelf/genkit_shelf.dart';
import 'package:shelf_router/shelf_router.dart';
final genkit = GenkitRouter()
..addAgent(weatherAgent) // /weatherAgent, /weatherAgent/getSnapshot, ...
..addAgent(statelessAgent); // turn route only
final app = Router()..mount('/api/', genkit.asShelfHandler());

Browser clients on another origin need CORS. Pass CorsOptions to serve() or asShelfHandler(). The JavaScript client sends an X-Genkit-Stream-Id header on streaming requests, so allow it along with the defaults:

await genkit.serve(
cors: const CorsOptions(
allowedOrigins: ['https://myapp.dev'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Genkit-Stream-Id'],
),
);

Serve agents over HTTP with serve_agent() from genkit_fastapi.exp. The turn route is always mounted. /getSnapshot and /abort are mounted only when the agent has a session store.

from fastapi import FastAPI, Header, HTTPException
from genkit.exp import Genkit
from genkit_fastapi.exp import serve_agent
from genkit_google_cloud.exp import FirestoreSessionStore
from genkit_google_genai import GoogleAI
ai = Genkit(plugins=[GoogleAI()])
app = FastAPI()
async def user_context(authorization: str | None = Header(default=None)) -> dict[str, object]:
if not authorization or not authorization.startswith('Bearer '):
raise HTTPException(status_code=401, detail='Missing bearer token.')
token = authorization.removeprefix('Bearer ').strip()
if not token:
raise HTTPException(status_code=401, detail='Empty bearer token.')
# Verify the token signature/claims and load the caller identity in production.
return {'uid': token}
weather_agent = ai.define_agent(
name='weatherAgent',
model=GoogleAI.gemini('gemini-flash-latest'),
system='You help with weather questions.',
store=FirestoreSessionStore(),
)
app.include_router(
serve_agent(weather_agent, context_dependency=user_context),
prefix='/api',
)
# Routes: /api/weatherAgent, /api/weatherAgent/getSnapshot, /api/weatherAgent/abort

The turn endpoint handles both streaming and non-streaming requests. The snapshot endpoint reads by snapshotId or returns the latest leaf by sessionId, and answers 404 when no snapshot matches. The abort endpoint takes a snapshotId and cancels running background work. Routes mount at /<agent name> under the router prefix; pass base_path= to serve_agent() to use a different path.

context_dependency is a FastAPI dependency that returns a dict. Genkit threads that dict into the action as context on the turn, snapshot, and abort routes, so auth and other request-scoped values resolve once through FastAPI’s Depends graph. Tools and custom agents read the same map from ctx.context. Raise FastAPI’s HTTPException here to reject a request: FastAPI resolves the dependency before Genkit runs, so it owns the error response.

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=true for server-sent events.
  • POST /agents/{name}/getSnapshot (or /api/{name}/getSnapshot): Exists when the agent has a session store. Use it to read by snapshotId or by latest sessionId.
  • 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 once the row settles, so a client follows a detached run without a tight polling loop. One request holds for at most the agent’s wait limit, 25 seconds unless the agent sets aix.WithMaxSnapshotWait, and then answers with the row as it stands. A client that gets a pending or aborting row back sends the request again. Keep the limit under the shortest request or idle timeout of the proxies between the client and the server. Both read routes accept "metadataOnly": true in data to return the status, finish reason, parent, and timestamps without the conversation.

JavaScript has one more optional companion, POST /api/{name}/waitForSnapshot, served from the agent’s waitForSnapshotAction (see Express routes for its body and limit). A client that gets a pending or aborting snapshot back sends the request again, so keep maxSnapshotWaitMs under the shortest request or idle timeout of the proxies between the client and the server. The abort route needs only a session store, but running work stops only when the store implements onSnapshotStateChange(); see Session stores.

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.

Terminal window
curl -X POST http://localhost:8080/agents/chat \
-H 'content-type: application/json' \
-d '{"data":{"message":{"role":"user","content":[{"text":"Weather in Tokyo?"}]}}}'

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:

FieldMeaning
messageThe last model response message of the conversation.
sessionIdThe conversation’s ID. The framework assigns it on the first invocation and it is stable across resumes.
snapshotIdThe most recent turn-end snapshot. Present only when the agent has a session store.
stateThe full SessionState. Present only for client-managed agents, those with no store.
artifactsArtifacts produced during the session.
finishReasonWhy the invocation finished: stop, length, blocked, interrupted, other, unknown, aborted, detached, or failed.
errorStructured failure details. Present only when finishReason is failed.

A Go server also sets error when finishReason is aborted, and adds usage: the token usage of the agent’s own model calls in this invocation, failed turns included. usage is omitted for a detached run.

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:

Terminal window
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.

Terminal window
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":{}}}}'

Stream a turn as server-sent events:

Terminal window
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 as modelChunk, customPatch, artifact, and turnEnd.
  • {"result": <AgentOutput>} is the terminal frame and the only one that carries sessionId.
  • {"error": {"status": ..., "message": ...}} replaces the terminal frame when the stream fails.

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.

aix.ApplyPatch and aix.Diff return numbers as json.Number, so an integer above 2^53, such as a UnixNano time or a token count, keeps its exact value. Marshal the result and unmarshal it into your state type, or assert json.Number rather than float64 when you read a value directly.

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:

Terminal window
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.

Read a snapshot:

Terminal window
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:

Terminal window
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:

Terminal window
curl -X POST http://localhost:8080/agents/chat/waitForSnapshot \
-H 'content-type: application/json' \
-d '{"data":{"snapshotId":"9f2a41d6-0c2b-4f0e-8a7a-3f9d1c6b2e40"}}'
Terminal window
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 with the status the snapshot had before the abort, so pending for a run that was still going, and flips the row to aborting. Call waitForSnapshot until the row settles to learn how the run ended.

An abort answers aborting for a run that was still going. Call waitForSnapshot until the row settles to learn how the run ended.

The abort route returns the snapshot’s status from before the call: pending if the run was still going (it is now aborted), or its terminal status if it had already settled.

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 snapshotId or sending state to a store-backed agent) fails immediately with an HTTP 4xx error before running the turn.

A request-level failure has a JSON body that names the status, so a client can tell apart statuses that share an HTTP code:

HTTP/1.1 400 Bad Request
Content-Type: application/json
{"status":"INVALID_ARGUMENT","message":"invalid argument"}

Sending state to a store-backed agent, or a sessionId to a client-managed one, fails with FAILED_PRECONDITION (400). An unknown snapshotId fails with NOT_FOUND (404).

Outside the dev environment, an error not built with status.PublicErrorf goes out with the generic message for its status, so hosts, queries, and credentials in error text stay in the server log. A served agent applies the same rule to the error of a failed turn and to the errors in the snapshots its getSnapshot and waitForSnapshot routes return. Errors about the caller’s own input, such as an invalid message, a resume that does not match the history, or a snapshot from another session, keep their text. The store and in-process callers always see the full text. Only genkit.Handler, and so Route.Handler, applies this rule: an agent you call from a handler of your own returns its errors unchanged. See Error types.

Sending state to a store-backed agent fails with 400 FAILED_PRECONDITION, and a message that fails validation fails with 400 INVALID_ARGUMENT naming the field. An unknown snapshotId in init resolves as a failed turn instead: HTTP 200 with finishReason: "failed" and error.status set to NOT_FOUND.

serve_agent() sends a PublicError’s status and message on request-level failures and redacts any other exception to 500 INTERNAL. A failed turn returns HTTP 200 with its error unchanged. In context_dependency, raise FastAPI’s HTTPException instead. See Errors and timeouts for the full status mapping.

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.

Code in the same process as the agent holds the agent value and calls RunText, Run, or Connect directly; see Run and stream agents. A Go app that calls an agent another app serves uses a remote agent.

genkitx.DefineRemoteAgent connects to an agent that another Genkit app serves over HTTP, in Go, JavaScript, or Dart. It returns an *aix.AgentHandle, the same handle LookupAgent returns for an agent in this process, and registers the agent under the name you give it. The agents middleware, the Dev UI, and LookupAgent then reach it by name.

package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
aix "github.com/firebase/genkit/go/ai/exp"
"github.com/firebase/genkit/go/genkit"
genkitx "github.com/firebase/genkit/go/genkit/exp"
)
func main() {
ctx := context.Background()
g := genkit.Init(ctx, genkit.WithExperimental())
billing := genkitx.DefineRemoteAgent(g, "billing", "https://billing.internal/agents/billing",
aix.WithDescription[any]("Answers billing questions."),
aix.WithAgentMetadata(&aix.AgentMetadata{
StateManagement: aix.AgentStateManagementServer,
Abortable: true,
}),
aix.WithHeaders(func(ctx context.Context) (http.Header, error) {
return http.Header{"Authorization": {"Bearer " + os.Getenv("BILLING_TOKEN")}}, nil
}),
)
out, err := billing.RunText(ctx, "Why was I charged twice?")
if err != nil {
log.Fatalf("call billing agent: %v", err)
}
fmt.Println(out.Message.Text())
next, err := billing.RunText(ctx, "Refund the second charge.",
aix.WithSessionID[json.RawMessage](out.SessionID),
)
if err != nil {
log.Fatalf("continue billing conversation: %v", err)
}
fmt.Println(next.Message.Text())
}

The URL is the agent’s turn route. The handle expects the snapshot companions next to it, at /getSnapshot, /waitForSnapshot, and /abort. DefineRemoteAgent sends no request; it panics if the name is empty or the URL is not an absolute http or https URL without user info, query, or fragment.

The options are aix.RemoteAgentOption values:

  • aix.WithHTTPClient(client) sets the *http.Client that reaches the server. Use it for authentication with an ID token or OAuth2 client, for mTLS, and for transport timeouts. The default is http.DefaultClient. The handle refuses redirects unless the client sets its own CheckRedirect, so a request body and its credentials never go to another host.
  • aix.WithHeaders(fn) adds headers to every request, such as a bearer token read from ctx. An error from fn fails the request before it is sent.
  • aix.WithAgentMetadata(meta) declares what the server supports: who manages state, and whether background work can be aborted. Callers such as the agents middleware decide on it, so declare only what the server really supports. Without it the capabilities are unknown, and callers find out from the agent’s answers.
  • aix.WithDescription[any](text) sets the description that Ref() carries and that the agents middleware shows the model. A remote agent has no Go state type, so any type argument works.
  • aix.WithRouteURLs(aix.AgentRouteURLs{...}) points the companions at other URLs, for a server that does not mount them next to the turn route, such as a Next.js app with one route file per action. An empty field keeps its default.

A remote handle works like a local one, as far as the server allows:

  • Turns stream over server-sent events, and the call returns the final output. The handle has no Connect, so continue a conversation with aix.WithSessionID or aix.WithSnapshotID on each call.
  • Failures keep their status name. A failed turn resolves as a failed AgentOutput, as it does in process. A route the server does not publish fails with UNIMPLEMENTED. Match a decoded error with errors.Is against the base sentinel of its status, such as status.ErrNotFound.
  • Background work works across the wire. RunDetached, Task, WaitForSnapshot, and Abort use the companion routes. WaitForSnapshot asks again each time the server’s wait limit passes, until the snapshot settles or ctx ends, and falls back to polling getSnapshot when the server has no wait route.
  • A turn is never retried. The server may have started it, so the handle reports the failure and leaves the retry to you. aix.IsRetryableReadError(err) reports whether a failed snapshot read may succeed if you make it again.
  • The caller’s trace travels. Each request carries a traceparent header from ctx.

To use a remote agent as a sub-agent, pass billing.Ref() to the agents middleware. See Multi-agent delegation. go/samples/basic-agents-remote runs an orchestrator that delegates to an agent served by a second process.

aix.NewRemoteAgent(name, url, opts...) builds the same handle without registering it, for code that only calls the agent directly. To reach agents over a protocol other than Genkit’s HTTP routes, implement aix.AgentTransport, and aix.SnapshotTransport for snapshot reads and aborts, then build a handle over it with aix.NewAgentHandle.

For a browser or mobile frontend in front of a Go backend, use the JavaScript client. It is in the genkit npm package:

Terminal window
npm i genkit
import { 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.

The snippets below apply whichever backend language you selected.

remoteAgent() from the genkit npm package (npm i genkit) creates a browser-safe client with the same AgentAPI shape as a local agent.

import { remoteAgent } from 'genkit/beta/client';
const agent = remoteAgent<WeatherState>({
url: 'http://localhost:8080/api/weatherAgent',
});
const chat = agent.chat();
const res = await chat.send('Weather in Tokyo?');
console.log(res.text);

Options:

  • url is required and sends normal and streaming turns.
  • getSnapshotUrl defaults to ${url}/getSnapshot and loads saved snapshots for server-managed agents.
  • waitForSnapshotUrl defaults to ${url}/waitForSnapshot. task.wait() and agent.waitForSnapshot() use it to block on the server, and fall back to polling getSnapshotUrl when the route is not mounted.
  • abortUrl defaults to ${url}/abort and cancels detached background turns.
  • headers can be a static object or an async function called for each request. Use the function form when tokens rotate or are fetched from the current frontend session.
  • stateManagement explicitly declares server or client state. The client otherwise infers the mode from responses.

sendStream() returns a turn that exposes a chunk stream and a final response.

const turn = agent.chat().sendStream('Write a long report.');
for await (const chunk of turn.stream) {
if (chunk.text) process.stdout.write(chunk.text);
if (chunk.custom) updateStatus(chunk.custom);
}
const res = await turn.response;

remoteAgent() is createAgentAPI(remoteAgentTransport(options)). To reach an agent over something other than HTTP streaming, such as a WebSocket, a callable function, or a queue, implement AgentTransport and wrap it with createAgentAPI. You get the same AgentAPI as remoteAgent() returns, so chat(), loadChat(), getSnapshot(), waitForSnapshot(), and abort() work, along with chat.detach() and task.wait(). The transport’s own options type becomes the per-chat and per-call options. In this sketch, socket stands in for your own connection and WeatherState for your agent’s state type:

import { createAgentAPI, type AgentTransport } from 'genkit/beta/client';
type SocketOptions = { requestId?: string };
const transport: AgentTransport<SocketOptions> = {
runTurn(input, init, { abortSignal, requestId }) {
// Start the turn; return its chunk stream and a promise for its output.
const call = socket.startTurn({ input, init, requestId, abortSignal });
return { stream: call.chunks, output: call.result };
},
getSnapshot: (lookup, opts) => socket.getSnapshot(lookup, opts?.requestId),
abort: (snapshotId, opts) => socket.abort(snapshotId, opts?.requestId),
};
// Pass both type arguments: createAgentAPI<State>(t) alone resets the options to `never`.
const agent = createAgentAPI<WeatherState, SocketOptions>(transport);
const chat = agent.chat({}, { requestId: 'req-42' });

To decorate HTTP calls (logging, retries), wrap remoteAgentTransport(options) instead of starting from scratch. waitForSnapshot on the transport is optional; without it, waits poll getSnapshot.

remoteAgent() from package:genkit/experimental_client.dart creates a platform-safe client with the same AgentApi shape as a local agent.

import 'package:genkit/experimental_client.dart';
final agent = remoteAgent(
url: 'http://localhost:8080/api/weatherAgent',
);
void main() async {
final chat = agent.chat();
final res = await chat.send(text: 'Weather in Tokyo?');
print(res.text);
}

Options:

  • url is required and sends normal and streaming turns.
  • getSnapshotUrl specifies the endpoint to load saved snapshots for server-managed agents. It defaults to $url/getSnapshot, matching the /getSnapshot route mounted above, so you only need to set it when your server uses a different path.
  • abortUrl specifies the endpoint to cancel detached background turns. It defaults to $url/abort.
  • headers is an async function (FutureOr<Map<String, String>?> Function()) called per request, so it can resolve auth tokens dynamically. For static headers, return them from the function.

sendStream() returns a turn that exposes a chunk stream and a final response.

final chat = agent.chat();
final turn = chat.sendStream(text: 'Write a long report.');
await for (final chunk in turn.stream) {
if (chunk.text.isNotEmpty) stdout.write(chunk.text);
if (chunk.custom != null) updateStatus(chunk.custom!);
}
final res = await turn.response;

remote_agent() from genkit.exp.agent creates an AgentClient with the same chat surface as a local agent.

import os
from genkit.exp.agent import remote_agent
agent = remote_agent(
url='http://localhost:8080/api/weatherAgent',
state_management='server',
headers={'Authorization': f'Bearer {os.environ["WEATHER_API_TOKEN"]}'},
)
chat = agent.chat()
res = await chat.send('Weather in Tokyo?')
print(res.text)
# => Tokyo is 72°F and sunny.

Options:

  • url is required and sends normal and streaming turns.
  • state_management is required and declares 'server' or 'client' state.
  • get_snapshot_url and abort_url default to f'{url}/getSnapshot' and f'{url}/abort'.
  • headers accepts a static mapping or a sync/async callable resolved per request for rotating auth tokens.
  • state_schema optionally validates custom state on the client as a Pydantic model.
  • timeout is the longest wait in seconds for the next bytes from the server, so on a streamed turn it bounds the gap between chunks, not the whole turn. It defaults to 60.0; None waits indefinitely.
chat = agent.chat()
turn = chat.send_stream('Write a long report.')
async for chunk in turn.stream:
if chunk.text:
print(chunk.text, end='', flush=True)
if chunk.custom is not None:
print('\nstatus:', chunk.custom)
res = await turn.response

remote_agent() is AgentClient(HttpAgentTransport(...)). To reach an agent over something other than HTTP streaming, implement the AgentTransport protocol (a state_management attribute plus run_turn, get_snapshot, and abort_snapshot) and pass it to AgentClient. To decorate HTTP calls with logging or retries, subclass HttpAgentTransport instead. Both types come from genkit.exp.agent.

The remote client calls the primary endpoint with streamed action transport. It resolves dynamic headers per request, supports foreground aborts, applies streamed custom-state patches, and throws AgentError for failed turns.

The remote client calls the primary endpoint with streamed action transport. It resolves dynamic headers per request, supports foreground cancellation, applies streamed custom-state patches, returns an AgentResponse with finish_reason == AgentFinishReason.FAILED and res.error for failed turns, and raises GenkitError on HTTP 4xx/5xx or transport failures.

When using server-managed state, make sure the same auth and tenant checks apply to the primary endpoint and every companion endpoint. 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.

@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:

Terminal window
npm i @genkit-ai/vercel-ai

Point 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>
</>
);
}

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.

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.