Serve a useChat frontend from Go
The useChat hook from @ai-sdk/react posts a list of UI messages to an endpoint and reads back a stream of Server-Sent Events (SSE). The Go AI SDK writes that stream, so the frontend needs no adapter. This guide builds the endpoint the way the Shipyard demo does.
The protocol
DefaultChatTransport sends a POST with a JSON body:
{
"id": "chat-id",
"messages": [
{ "id": "m1", "role": "user", "parts": [{ "type": "text", "text": "Hello" }] }
],
"trigger": "submit-message",
"messageId": "m2"
}
Anything you pass in the transport's body option is merged into the same object. The messages array holds UI messages, not model messages: each has parts such as text, tool calls with their state, files and your own data parts.
The response is text/event-stream. Each event is data: <JSON chunk>, and the stream ends with data: [DONE]. The response also carries the header X-Vercel-AI-UI-Message-Stream: v1, which useChat checks.
A minimal endpoint
This program runs an agent on the messages useChat sends. It validates first, so a bad request gets a plain 400 before any streaming starts.
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"github.com/digitallysavvy/go-ai/pkg/agent"
"github.com/digitallysavvy/go-ai/pkg/ai"
"github.com/digitallysavvy/go-ai/pkg/providers/anthropic"
)
type chatRequest struct {
Messages json.RawMessage `json:"messages"`
}
func main() {
model, err := anthropic.New(anthropic.Config{APIKey: os.Getenv("ANTHROPIC_API_KEY")}).
LanguageModel(anthropic.ClaudeSonnet5_5)
if err != nil {
log.Fatal(err)
}
assistant := agent.NewToolLoopAgent(agent.AgentConfig{
Model: model,
System: "You are a concise assistant.",
})
http.HandleFunc("POST /api/chat", func(w http.ResponseWriter, r *http.Request) {
var req chatRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<20)).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// Validates the UI messages against the agent's tools and converts
// them to model messages. Nothing is written to w yet.
chunks, _, err := agent.CreateAgentUIStreamFromUIMessages(r.Context(), assistant,
agent.CreateAgentUIStreamFromUIMessagesOptions{UIMessages: []byte(req.Messages)})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Sets the stream headers, writes 200 and flushes every chunk.
if err := ai.PipeUIMessageChunksToResponse(chunks, w, nil); err != nil {
log.Printf("write stream: %v", err)
}
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
Pass the request context. r.Context() is cancelled when the browser disconnects, which stops the agent and the provider call.
Choose a helper
| You have | Use |
|---|---|
UI messages from useChat, and you want one call | agent.PipeAgentUIStreamFromUIMessagesToResponse(ctx, agent, opts, w) |
| UI messages, and you want to answer 400 on bad input | agent.CreateAgentUIStreamFromUIMessages, then ai.PipeUIMessageChunksToResponse |
A stream you built with ai.CreateUIMessageStreamWithOptions | ai.PipeUIMessageChunksToResponse |
A *ai.StreamTextResult from ai.StreamText | ai.PipeUIMessageStreamToResponse |
A framework that wants an *http.Response | agent.CreateAgentUIStreamResponseFromUIMessages or ai.CreateUIMessageChunksResponse |
The Pipe* helpers set the status and headers when the writer is an http.ResponseWriter, so do not call WriteHeader or set the SSE headers yourself. Headers you set earlier, such as CORS headers, stay in place. See Stream transport helpers and Agent UI stream helpers for every signature.
PipeAgentUIStreamFromUIMessagesToResponse returns an error for invalid messages before it writes anything, but it also returns write errors after streaming starts, and the two look the same to the caller. Use the two-step form above when you want to send a 400.
Errors before and after streaming
Two kinds of failure need different handling.
Before streaming starts, the response is still open. Bad JSON, a messages value that fails validation, and messages that can't be converted all return an error from CreateAgentUIStreamFromUIMessages. Answer with a 4xx status. useChat sets its error state and status becomes error.
After streaming starts, the status line has already gone out as 200. A provider failure, a tool that panics, or an invalid approval signature surfaces as an error chunk in the stream:
data: {"type":"error","errorText":"An error occurred."}
useChat shows it through the same error state. The default text is generic so that provider messages, URLs and keys do not reach the browser. To send real text, set an OnError function that maps the error to the string you want users to see:
chunks, _, err := agent.CreateAgentUIStreamFromUIMessages(ctx, assistant, agent.CreateAgentUIStreamFromUIMessagesOptions{
UIMessages: []byte(req.Messages),
UIMessageStream: ai.UIMessageStreamResultOptions{
OnError: func(err error) string {
log.Printf("chat error: %v", err)
return "Something went wrong. Try again."
},
},
})
If you build the stream yourself with ai.CreateUIMessageStreamWithOptions, the same OnError goes on ai.UIMessageStreamOptions. The demo returns err.Error() because it runs locally; do not do that in production.
Keep-alive
Coding agents and slow tools can leave a stream quiet for a minute. Idle connections get closed by proxies and load balancers. UIMessageStreamResponseInit.KeepAliveMs makes the pipe send an opening : stream-open comment right away, then a : keep-alive comment whenever the stream has been silent for that long. SSE clients ignore comments.
keepAlive := 15 * time.Second
err := ai.PipeUIMessageChunksToResponse(chunks, w, &ai.UIMessageStreamResponseInit{
KeepAliveMs: &keepAlive,
})
The field is a *time.Duration, despite the Ms suffix, which it keeps for parity with the TypeScript option. Pick an interval shorter than your proxy's idle timeout.
CORS
When the frontend and the API live on different origins, such as localhost:3000 and localhost:8080, the browser sends a preflight OPTIONS request because the body is JSON. Answer it, and name the one origin you trust:
func withCORS(origin string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
If you send an Authorization header from the client, add it to Access-Control-Allow-Headers. If you serve the frontend and the API from one origin, or proxy /api through your frontend server, you do not need CORS.
Proxies and buffering
SSE only works if every layer between Go and the browser passes bytes through as they arrive. The SDK flushes after every chunk and sends Cache-Control: no-cache and X-Accel-Buffering: no. The rest is up to your stack:
- Wrap the
ResponseWritercarefully. Logging, metrics and compression middleware often wrap the writer in a type that does not implementhttp.Flusher. The SDK then cannot flush, and the output arrives in large blocks. ImplementFlushon your wrapper by delegating to the underlying writer, or skip the wrapper for this route. - Do not compress the stream. Gzip buffers until it has a block to emit. Exclude
text/event-streamfrom compression. - nginx. The
X-Accel-Buffering: noheader already tells nginx not to buffer this response. If you still see batching, setproxy_buffering off;andproxy_http_version 1.1;on the location, and raiseproxy_read_timeoutabove your keep-alive interval. - CDNs and platform proxies. Check that streaming responses are enabled and that the idle timeout is longer than your keep-alive interval.
- Server timeouts.
http.Server.WriteTimeoutends the stream when it expires. Leave it at zero for streaming routes, and setReadHeaderTimeoutas the demo does.
Troubleshooting
useChat shows nothing.
Open the network tab and check the /api/chat response. A non-200 status or an HTML error page means the request failed before streaming; look at your server log. A CORS error in the console means the preflight was not answered. A 200 with the right content type but no events points to a proxy that holds the response.
Output arrives all at once at the end.
Something is buffering. Test the Go server directly with curl -N -X POST localhost:8080/api/chat -H 'Content-Type: application/json' -d '{"messages":[...]}'. If chunks appear one by one there, the problem is a proxy, compression or a wrapped ResponseWriter in front of it. If they arrive together even there, check for a middleware that wraps the writer.
It streams, but useChat reports a parse error.
The response is missing X-Vercel-AI-UI-Message-Stream: v1, or something else is writing to the body. Use the Pipe* helpers and write nothing yourself.
The stream stops after about a minute.
An idle timeout closed the connection. Turn on KeepAliveMs.
Custom data parts
Data parts let the server send typed side-channel data, such as progress or the activity log of a coding agent, as part of the assistant message. Build the stream with ai.CreateUIMessageStreamWithOptions, write data chunks through the writer, and merge the agent's stream into it:
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"time"
"github.com/digitallysavvy/go-ai/pkg/agent"
"github.com/digitallysavvy/go-ai/pkg/ai"
"github.com/digitallysavvy/go-ai/pkg/providers/anthropic"
)
func main() {
model, err := anthropic.New(anthropic.Config{APIKey: os.Getenv("ANTHROPIC_API_KEY")}).
LanguageModel(anthropic.ClaudeSonnet5_5)
if err != nil {
log.Fatal(err)
}
assistant := agent.NewToolLoopAgent(agent.AgentConfig{Model: model})
http.HandleFunc("POST /api/chat", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Messages json.RawMessage `json:"messages"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<20)).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
ctx := r.Context()
chunks, _ := ai.CreateUIMessageStreamWithOptions(ctx, ai.UIMessageStreamOptions{
OnError: func(err error) string {
log.Printf("chat error: %v", err)
return "Something went wrong."
},
Execute: func(writer ai.UIMessageStreamWriter) {
// A data part with an id is replaced in place when you write
// the same id again.
writer.Write(ai.UIMessageChunk{
"type": "data-status",
"id": "status",
"data": map[string]interface{}{"phase": "thinking"},
})
stream, errs, err := agent.CreateAgentUIStreamFromUIMessages(ctx, assistant,
agent.CreateAgentUIStreamFromUIMessagesOptions{UIMessages: []byte(req.Messages)})
if err != nil {
// The response is already open, so report it as a chunk.
writer.Write(ai.UIMessageChunk{"type": "error", "errorText": err.Error()})
return
}
writer.Merge(stream)
go func() {
for err := range errs {
if err != nil {
log.Printf("agent stream: %v", err)
}
}
}()
},
})
keepAlive := 15 * time.Second
if err := ai.PipeUIMessageChunksToResponse(chunks, w, &ai.UIMessageStreamResponseInit{KeepAliveMs: &keepAlive}); err != nil {
log.Printf("write stream: %v", err)
}
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
Rules for data chunks:
- The
typeisdata-plus a name you choose. The client sees it as a part of that type. - Writing the same
idagain replaces the part'sdatain place. The demo sends onedata-coderpart per coding run and rewrites it as work progresses, using the tool call ID as the part ID. - Add
"transient": truefor data that should reach the client'sonDatacallback but not be stored in the message history. - Data must be JSON-serializable. Send empty slices, not nil, when the client expects an array:
nullbreaks a client that calls.mapon it. Executemust return after it has set up the stream. Anything you pass towriter.Mergekeeps streaming afterExecutereturns.
Because the data parts live in the assistant message, they come back to the server on the next request. Validation accepts them. Pass ConvertDataPart in CreateAgentUIStreamFromUIMessagesOptions if you want any of them turned into text or file parts for the model; by default they are ignored.
The client
On the client, point DefaultChatTransport at your endpoint. body can be an object or a function; the function runs on every request, including the automatic resend after a tool approval.
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport, type UIMessage } from 'ai';
import { useMemo, useRef, useState } from 'react';
type ChatMessage = UIMessage<never, { status: { phase: string } }>;
export default function Chat() {
const [mode, setMode] = useState('default');
const modeRef = useRef(mode);
modeRef.current = mode;
const transport = useMemo(
() =>
new DefaultChatTransport<ChatMessage>({
api: 'http://localhost:8080/api/chat',
body: () => ({ mode: modeRef.current }),
}),
[],
);
const { messages, sendMessage, status, error } = useChat<ChatMessage>({ transport });
const [input, setInput] = useState('');
return (
<div>
{messages.map((m) => (
<div key={m.id}>
{m.parts.map((part, i) => {
if (part.type === 'text') return <p key={i}>{part.text}</p>;
if (part.type === 'data-status') return <em key={i}>{part.data.phase}</em>;
return null;
})}
</div>
))}
{error && <p>{error.message}</p>}
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage({ text: input });
setInput('');
}}
>
<input value={input} onChange={(e) => setInput(e.target.value)} disabled={status !== 'ready'} />
</form>
</div>
);
}
The mode field arrives in the JSON body next to messages. Read it by adding a field to your request struct, as the demo does with agent. The second type argument to UIMessage types your data parts, so part.data is checked against what the server sends.
See it in the demo
server/chat.go: the request struct, the 400 for a bad body,CreateUIMessageStreamWithOptionswithExecute,CreateAgentUIStreamFromUIMessages,Merge, and the keep-alive pipe.server/main.go:withCORS, the HTTP server timeouts and graceful shutdown.web/app/page.tsx:DefaultChatTransportwith abodyfunction anduseChat.web/lib/types.ts: the typeddata-coderpart that mirrors the Go struct.
Next: Tool approval end to end.