Serve an AG2 agent over AG-UI and chat with it from assistant-ui, a React library of chat components. assistant-ui's @assistant-ui/react-ag-ui package reads the AG-UI event stream directly from the browser, so no extra runtime process sits between the UI and AG2.
importrandomfromtypingimportAnnotatedfromag2importAgent,Context,toolfromag2.ag_uiimportAGUIStream,read_run_inputfromag2.configimportOpenAIResponsesConfigfromag2.middleware.builtin.tools.approvalimportApprovalRequiredfromfastapiimportFastAPI,Requestfromfastapi.middleware.corsimportCORSMiddlewarefromfastapi.responsesimportJSONResponse,Response,StreamingResponsefrompydanticimportFieldCONDITIONS=("sunny","partly cloudy","overcast","light rain","windy")@tool(description="Get the current weather for a city.")defget_weather(city:Annotated[str,Field(description="The city name, e.g. 'Lisbon'")],)->dict[str,str|int]:# A stand-in for a real weather API, seeded by the city so answers are stable.rng=random.Random(city.lower())return{"city":city,"temperature_c":rng.randint(-5,32),"conditions":rng.choice(CONDITIONS),}@tool(description=("Save a city to the user's favorites. Use only when the user asks to save, ""pin or remember a city. The user confirms before anything is saved."),# The call is held as an AG-UI interrupt until the client approves or rejects it.middleware=[ApprovalRequired("Save {tool_arguments} to your favorites?",allow_always=False)],)defsave_favorite_city(context:Context,city:Annotated[str,Field(description="The city name to save, e.g. 'Lisbon'")],)->list[str]:# Variables are sent to the client as the shared state snapshot.favorites=[*context.variables.get("favorites",[]),city]context.variables["favorites"]=favoritesreturnfavoritesagent=Agent("WeatherAgent",prompt=("You are a concise weather assistant. Use get_weather for weather questions. ""Use save_favorite_city only when the user asks to save a city. ""After a tool call, answer in one short sentence."),config=OpenAIResponsesConfig(model="gpt-6-luna",streaming=True),tools=[get_weather,save_favorite_city],)stream=AGUIStream(agent)app=FastAPI()# The browser calls this server directly, so the Next.js dev server's origin is allowed.app.add_middleware(CORSMiddleware,allow_origins=["http://localhost:3000"],allow_methods=["GET","POST"],allow_headers=["*"],)@app.get("/agent")asyncdefcapabilities()->JSONResponse:"""Tell the client what the agent supports before a run starts."""returnJSONResponse(stream.capabilities().model_dump(by_alias=True,exclude_none=True))@app.post("/agent")asyncdefrun_agent(request:Request)->Response:"""Run the agent on an AG-UI `RunAgentInput` and stream its events as SSE."""try:run_input=read_run_input(awaitrequest.body())exceptValueError:returnJSONResponse({"error":"invalid AG-UI RunAgentInput body"},status_code=400)returnStreamingResponse(stream.dispatch(run_input,accept=request.headers.get("accept")),media_type="text/event-stream",)
GET /agent returns the agent's capabilities. POST /agent reads the body with read_run_input rather than declaring a RunAgentInput parameter: a newer client may send fields this server does not know, and read_run_input drops them instead of FastAPI refusing the request with 422. See Basic server example for the same pattern without the tools.
You do not need the example's own Python echo server, server/agent.py: the AG2 server above answers on the same URL. Open http://localhost:3000 and ask for the weather in a city. The reply streams in, and the get_weather call appears in the thread with its result.
The whole client wiring is one provider. If you add AG2 to an existing assistant-ui app instead, install @assistant-ui/react-ag-ui and @ag-ui/client and wrap your thread in:
AG2 runs get_weather on the server. To show its result as a card rather than raw JSON, register it as a backend tool with a renderer. Add the toolkit to the page that renders the thread:
import{AuiConfig,AuiProvider,Tools,defineToolkit,useAui}from"@assistant-ui/react";typeWeather={city:string;temperature_c:number;conditions:string};consttoolkit=defineToolkit({get_weather:{type:"backend",render:({args,result})=>{constweather=resultasWeather|undefined;returnweather?(<p>{weather.city}:{weather.temperature_c}°C,{weather.conditions}</p>):(<p>Lookingup{String(args["city"]??"…")}</p>);},},});// Inside your page component:constaui=useAui();constconfig=AuiConfig({tools:Tools({toolkit})});// <AuiProvider extends={aui} config={config}><Thread /></AuiProvider>
Tools that the browser registers with an execute function, such as the example's browser_alert, reach AG2 as frontend tools: AG2 ends the run with the call pending, assistant-ui runs it, and the result starts the next run.
save_favorite_city is wrapped in ApprovalRequired, so AG2 ends the run with an AG-UI interrupt instead of calling it (see Gating a tool call). assistant-ui exposes the open interrupts through useAgUiInterrupts and resumes the run with useAgUiSubmitInterruptResponses. AG2 reads a boolean payload as the decision, true to approve and false to deny:
Render PendingApprovals anywhere inside the runtime provider, then ask the agent to save a city.
Use a boolean payload
AG2 accepts true, false or a string as the answer to a tool-call approval. An object payload, such as { approved: true }, is refused with INTERRUPT_PAYLOAD_REFUSED.
After you approve a save, the list updates. assistant-ui sends the latest state back as the next run's state, so context.variables["favorites"] is already filled when the agent runs again. Because the browser can edit that state, never read permissions or identity from it.
Answer the interrupt first. While an interrupt is open, a message typed in the composer is not sent. Answer it, or use assistant-ui's useAgUiSteerAway, which cancels the open interrupts and sends the new message.
Resume proof. assistant-ui's resume entry carries no metadata, so it cannot satisfy a server started with AGUIStream(agent, require_resume_proof=True). Keep the default for this client. See Asking the human a question.
One process per paused run.AGUIStream holds a paused run in the server's memory, so the resume must reach the same process that issued the interrupt.