écrits/tutorial/2026/07
Tutorial9 juil. 2026·30 min

Protocole AG-UI avec Next.js : diffuser vos agents IA vers le frontend avec des événements typés (2026)

Apprenez à construire un agent conforme au protocole AG-UI en TypeScript et à le diffuser dans une interface Next.js. Ce guide pratique couvre la taxonomie des événements, la classe AbstractAgent, les route handlers SSE, HttpAgent, l'état partagé via JSON Patch et la génération d'UI à partir des appels d'outils.

Trois protocoles définissent désormais la pile d'interopérabilité des agents, et la plupart des équipes n'en ont adopté que deux.

MCP normalise la façon dont un agent dialogue avec ses outils et ses données. A2A normalise la façon dont un agent dialogue avec un autre agent. Mais la couche où un agent dialogue avec un humain — diffuser des tokens dans une fenêtre de conversation, afficher un appel d'outil à moitié formé, synchroniser un plan que l'utilisateur peut modifier en cours d'exécution — est restée obstinément artisanale. Chaque framework a inventé sa propre forme de payload SSE. LangGraph diffuse d'une manière, CrewAI d'une autre, votre boucle maison d'une troisième. Changez de backend, et vous réécrivez le frontend.

AG-UI (le protocole d'interaction agent-utilisateur) comble cette lacune. C'est un protocole ouvert fondé sur les événements, avec une vingtaine de types d'événements standardisés circulant sur un transport interchangeable — Server-Sent Events, WebSockets ou webhooks. Si votre agent émet des événements AG-UI, n'importe quel frontend compatible AG-UI peut l'afficher. Retirez LangGraph, remplacez-le par une boucle Anthropic brute, et l'interface n'y verra que du feu.

Dans ce tutoriel, vous construirez l'ensemble depuis zéro : un agent AG-UI personnalisé propulsé par Claude, exposé via un route handler Next.js en SSE, et consommé par un client React qui affiche le texte en flux, les appels d'outils en direct, et un objet d'état partagé que l'agent et l'utilisateur modifient tous les deux.

Prérequis

Avant de commencer, assurez-vous de disposer de :

  • Node.js 20 ou plus et d'un gestionnaire de paquets (ce guide utilise pnpm, mais npm fonctionne à l'identique)
  • Une familiarité avec Next.js 15 ou 16 — App Router, route handlers et composants client
  • Une aisance avec TypeScript, y compris les génériques et les unions discriminées
  • Une clé API Anthropic obtenue sur console.anthropic.com, pour l'étape où nous branchons une véritable intelligence
  • Une compréhension de base des Observables RxJS — nous expliquerons ce qu'il faut savoir, mais savoir que observer.next() pousse une valeur dans le flux vous aidera

Vous n'avez besoin ni de CopilotKit, ni de LangGraph, ni d'aucun framework d'agents. AG-UI est délibérément agnostique, et construire directement contre le protocole brut reste le moyen le plus rapide de comprendre ce que ces frameworks font pour vous.

Ce que vous allez construire

Une seule application Next.js, en deux moitiés :

Backend — une sous-classe d'AbstractAgent qui encapsule l'API Anthropic Messages. Elle traduit le flux natif de Claude en événements AG-UI : cycle de vie de l'exécution, deltas de texte, appels d'outils et patchs d'état. Un route handler sur /api/agent sérialise ces événements en trames SSE.

Frontend — un composant client qui instancie HttpAgent, le pointe vers /api/agent et s'abonne au flux d'événements. Le texte s'affiche token par token. Les appels d'outils apparaissent sous forme de cartes de statut en direct avant même l'arrivée de leurs résultats. Un objet « plan de recherche » partagé reste synchronisé entre l'agent et l'utilisateur grâce aux deltas JSON Patch.

À la fin, vous comprendrez non seulement comment utiliser AG-UI, mais pourquoi sa taxonomie d'événements a exactement cette forme.

Étape 1 : Mise en place du projet

Créez une application Next.js neuve. AG-UI propose un starter officiel (npx create-ag-ui-app my-agent-app), mais nous partons du strict minimum pour que rien ne reste caché.

pnpm create next-app@latest ag-ui-demo --typescript --tailwind --app --no-src-dir
cd ag-ui-demo

Installez les paquets AG-UI ainsi que le SDK Anthropic :

pnpm add @ag-ui/client @ag-ui/core rxjs @anthropic-ai/sdk

Trois paquets font le travail :

  • @ag-ui/core contient le protocole lui-même — l'énumération EventType, les schémas Zod qui valident chaque événement, et les types TypeScript partagés. Aucun parti pris d'exécution.
  • @ag-ui/client fournit AbstractAgent (la classe de base que vous étendez côté serveur) et HttpAgent (le client qui consomme un endpoint SSE). Elle expose également l'interface AgentSubscriber.
  • rxjs apporte la primitive Observable que retourne AbstractAgent.run().

Ajoutez votre clé API dans .env.local :

ANTHROPIC_API_KEY=sk-ant-your-key-here

Ne versionnez jamais .env.local. Le .gitignore par défaut de Next.js l'exclut déjà, mais vérifiez avant votre premier push — les clés Anthropic fuitées sont moissonnées sur les dépôts publics en quelques minutes.

Étape 2 : Comprendre la taxonomie des événements

Tout, dans AG-UI, est un événement. Avant d'écrire la moindre ligne de code, assimilez ces catégories — c'est le protocole tout entier, et il tient dans votre tête.

Cycle de vie de l'exécution. RUN_STARTED et RUN_FINISHED encadrent une invocation d'agent. RUN_ERROR remplace RUN_FINISHED quand quelque chose casse. STEP_STARTED et STEP_FINISHED délimitent les phases internes d'une exécution, permettant à l'interface d'afficher « Recherche en cours… » puis « Synthèse… ».

Messages textuels. TEXT_MESSAGE_START annonce un nouveau message d'assistant avec un messageId et un role. TEXT_MESSAGE_CONTENT transporte un delta — un token ou un fragment. TEXT_MESSAGE_END le referme. Il existe aussi un TEXT_MESSAGE_CHUNK de commodité qui condense les trois quand un contrôle fin est superflu.

Appels d'outils. TOOL_CALL_START inclut le toolCallName. TOOL_CALL_ARGS diffuse les arguments JSON par fragments, à mesure que le modèle les produit. TOOL_CALL_END clôt l'appel, et TOOL_CALL_RESULT livre la sortie d'exécution. Ce détail du streaming des arguments compte : vous pouvez afficher une requête de recherche à demi rédigée avant même que le modèle ait fini de décider ce qu'il va chercher.

État. STATE_SNAPSHOT envoie un objet d'état complet. STATE_DELTA envoie un tableau d'opérations JSON Patch (RFC 6902) pour le muter de façon incrémentale. C'est le mécanisme derrière l'état partagé — l'agent possède un document, le frontend le reflète, et les deux peuvent le patcher.

Raisonnement et réflexion. THINKING_START, THINKING_TEXT_MESSAGE_CONTENT, REASONING_MESSAGE_CHUNK et leurs voisins exposent les traces de raisonnement étendu, afin que l'interface puisse afficher un panneau « processus de pensée » repliable, distinct de la réponse.

Portes de sortie. CUSTOM transporte des payloads nommés arbitraires. RAW fait passer l'événement natif du fournisseur sans le toucher. MESSAGES_SNAPSHOT réinitialise tout l'historique de conversation — utile après une reconnexion.

Chaque événement est un simple objet JSON doté d'un champ type. C'est tout. Pas de RPC, pas de poignée de main bidirectionnelle au niveau du protocole — juste un flux typé descendant de l'agent vers l'interface, tandis que les entrées utilisateur remontent dans des corps de requêtes HTTP ordinaires.

Étape 3 : Construire un agent minimal

Commencez par l'agent le plus simple possible, pour que la forme d'AbstractAgent soit sans ambiguïté. Créez lib/agent/simple-agent.ts :

import {
  AbstractAgent,
  BaseEvent,
  EventType,
  RunAgentInput,
} from "@ag-ui/client"
import { Observable } from "rxjs"
 
export class SimpleAgent extends AbstractAgent {
  run(input: RunAgentInput): Observable<BaseEvent> {
    const { threadId, runId } = input
 
    return new Observable<BaseEvent>((observer) => {
      // Ouvre l'exécution
      observer.next({
        type: EventType.RUN_STARTED,
        threadId,
        runId,
      } as BaseEvent)
 
      const messageId = crypto.randomUUID()
 
      observer.next({
        type: EventType.TEXT_MESSAGE_START,
        messageId,
        role: "assistant",
      } as BaseEvent)
 
      observer.next({
        type: EventType.TEXT_MESSAGE_CONTENT,
        messageId,
        delta: "Hello from AG-UI.",
      } as BaseEvent)
 
      observer.next({
        type: EventType.TEXT_MESSAGE_END,
        messageId,
      } as BaseEvent)
 
      // Ferme l'exécution
      observer.next({
        type: EventType.RUN_FINISHED,
        threadId,
        runId,
      } as BaseEvent)
 
      observer.complete()
    })
  }
}

Trois règles gouvernent run() :

  1. Encadrez toujours avec RUN_STARTED et un événement terminal. Une exécution qui n'émet jamais RUN_FINISHED ni RUN_ERROR laisse le client tourner indéfiniment.
  2. Le messageId doit rester stable sur tout le triplet START, CONTENT et END. Le client s'en sert pour router les deltas vers le bon tampon de message.
  3. Appelez observer.complete() quand le flux est terminé. L'oublier maintient la connexion SSE ouverte.

Le paramètre input porte tout ce dont l'agent a besoin : threadId, runId, le tableau messages (historique complet), un objet state, les tools que le frontend met à disposition, et context — des paires clé-valeur arbitraires que le frontend diffuse, comme la zone visible actuelle ou la ligne que l'utilisateur vient de sélectionner.

Étape 4 : Brancher Claude

Remplaçons maintenant la réponse en dur par un vrai modèle. Créez lib/agent/claude-agent.ts :

import {
  AbstractAgent,
  BaseEvent,
  EventType,
  RunAgentInput,
} from "@ag-ui/client"
import Anthropic from "@anthropic-ai/sdk"
import { Observable } from "rxjs"
 
const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
})
 
export class ClaudeAgent extends AbstractAgent {
  run(input: RunAgentInput): Observable<BaseEvent> {
    const { threadId, runId, messages } = input
 
    return new Observable<BaseEvent>((observer) => {
      const emit = (event: Record<string, unknown>) =>
        observer.next(event as BaseEvent)
 
      const execute = async () => {
        emit({ type: EventType.RUN_STARTED, threadId, runId })
 
        const messageId = crypto.randomUUID()
        let opened = false
 
        const stream = anthropic.messages.stream({
          model: "claude-sonnet-5",
          max_tokens: 2048,
          messages: messages
            .filter((m) => m.role === "user" || m.role === "assistant")
            .map((m) => ({
              role: m.role as "user" | "assistant",
              content: m.content ?? "",
            })),
        })
 
        for await (const chunk of stream) {
          if (
            chunk.type === "content_block_delta" &&
            chunk.delta.type === "text_delta"
          ) {
            if (!opened) {
              emit({
                type: EventType.TEXT_MESSAGE_START,
                messageId,
                role: "assistant",
              })
              opened = true
            }
            emit({
              type: EventType.TEXT_MESSAGE_CONTENT,
              messageId,
              delta: chunk.delta.text,
            })
          }
        }
 
        if (opened) {
          emit({ type: EventType.TEXT_MESSAGE_END, messageId })
        }
 
        emit({ type: EventType.RUN_FINISHED, threadId, runId })
        observer.complete()
      }
 
      execute().catch((error: unknown) => {
        emit({
          type: EventType.RUN_ERROR,
          message: error instanceof Error ? error.message : "Unknown error",
        })
        observer.complete()
      })
 
      // Ce nettoyage s'exécute si le client se désabonne en cours de flux
      return () => {
        // annulez ici la requête amont en production
      }
    })
  }
}

Remarquez le drapeau opened. Nous n'émettons TEXT_MESSAGE_START qu'à l'arrivée effective du premier delta de texte — sinon une exécution qui appelle immédiatement un outil ouvrirait une bulle de message vide que l'utilisateur verrait clignoter puis disparaître.

Le bloc catch fait lui aussi un vrai travail. Un rejet non géré à l'intérieur d'un Observable tue le flux en silence, sans aucun événement terminal. Le convertir en RUN_ERROR permet au frontend d'afficher un état d'échec plutôt que de rester figé.

La fonction de démontage retournée par le constructeur de l'Observable se déclenche à la déconnexion du client. En production, appelez-y stream.abort(). Sans cela, un utilisateur qui ferme son onglet en pleine génération continue de consommer des tokens jusqu'à ce que le modèle ait fini.

Étape 5 : Exposer l'agent en SSE

Le transport par défaut d'AG-UI est Server-Sent Events. Chaque événement devient une trame SSE : le préfixe littéral data: , l'événement sérialisé en JSON, puis deux sauts de ligne.

Créez app/api/agent/route.ts :

import { ClaudeAgent } from "@/lib/agent/claude-agent"
import type { RunAgentInput } from "@ag-ui/client"
import type { NextRequest } from "next/server"
 
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
 
export async function POST(req: NextRequest) {
  const input = (await req.json()) as RunAgentInput
  const agent = new ClaudeAgent()
  const encoder = new TextEncoder()
 
  const stream = new ReadableStream({
    start(controller) {
      const subscription = agent.run(input).subscribe({
        next(event) {
          const frame = `data: ${JSON.stringify(event)}\n\n`
          controller.enqueue(encoder.encode(frame))
        },
        error(err) {
          const payload = JSON.stringify({
            type: "RUN_ERROR",
            message: String(err),
          })
          controller.enqueue(encoder.encode(`data: ${payload}\n\n`))
          controller.close()
        },
        complete() {
          controller.close()
        },
      })
 
      // Se désabonne quand la connexion HTTP tombe
      req.signal.addEventListener("abort", () => {
        subscription.unsubscribe()
        controller.close()
      })
    },
  })
 
  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no",
    },
  })
}

Deux en-têtes vous épargneront des heures de débogage. no-transform empêche les proxys intermédiaires de compresser le flux (ce qui le met en tampon). X-Accel-Buffering: no indique à nginx de ne pas retenir les trames. Sans eux, le flux fonctionne à merveille en local et arrive en production sous forme d'un unique bloc massif.

export const runtime = "nodejs" compte également. Le runtime Edge fonctionne, mais Node vous donne la sémantique complète d'annulation de flux du SDK Anthropic.

Étape 6 : Consommer le flux avec HttpAgent

Passons au client. HttpAgent gère la connexion SSE, le parsing des trames, la validation des événements et l'accumulation des messages. Vous fournissez un AgentSubscriber — un objet de callbacks optionnels, un par type d'événement.

Créez lib/agent/client.ts :

import { HttpAgent } from "@ag-ui/client"
 
export const agent = new HttpAgent({
  url: "/api/agent",
})

Puis pilotez-le :

await agent.runAgent(
  { /* champs RunAgentInput supplémentaires */ },
  {
    onTextMessageStartEvent() {
      console.log("assistant message opened")
    },
    onTextMessageContentEvent({ event }) {
      process.stdout.write(event.delta)
    },
    onTextMessageEndEvent() {
      console.log("\nassistant message closed")
    },
    onToolCallStartEvent({ event }) {
      console.log("tool call:", event.toolCallName)
    },
    onToolCallArgsEvent({ event }) {
      console.log("args delta:", event.delta)
    },
    onToolCallResultEvent({ event }) {
      console.log("tool result:", event.content)
    },
    onRunErrorEvent({ event }) {
      console.error("run failed:", event.message)
    },
  },
)

Le pattern subscriber est la plus élégante trouvaille d'AG-UI. Vous n'écrirez jamais de switch sur les types d'événements, ne parserez jamais de trames SSE à la main, et ne vous demanderez jamais si un TEXT_MESSAGE_CONTENT est arrivé avant son START. HttpAgent valide l'ordre contre le schéma du protocole et lève une erreur en cas de violation.

Étape 7 : Afficher dans React

Reliez le subscriber à l'état du composant. Créez app/components/chat.tsx :

"use client"
 
import { HttpAgent } from "@ag-ui/client"
import { useCallback, useRef, useState } from "react"
 
type Message = { id: string; role: "user" | "assistant"; text: string }
 
export function Chat() {
  const [messages, setMessages] = useState<Message[]>([])
  const [running, setRunning] = useState(false)
  const [input, setInput] = useState("")
  const agentRef = useRef(new HttpAgent({ url: "/api/agent" }))
 
  const send = useCallback(async () => {
    if (!input.trim() || running) return
 
    const userMessage: Message = {
      id: crypto.randomUUID(),
      role: "user",
      text: input,
    }
    setMessages((prev) => [...prev, userMessage])
    setInput("")
    setRunning(true)
 
    const agent = agentRef.current
    agent.messages = [
      ...agent.messages,
      { id: userMessage.id, role: "user", content: userMessage.text },
    ]
 
    await agent.runAgent(
      {},
      {
        onTextMessageStartEvent({ event }) {
          setMessages((prev) => [
            ...prev,
            { id: event.messageId, role: "assistant", text: "" },
          ])
        },
        onTextMessageContentEvent({ event }) {
          setMessages((prev) =>
            prev.map((m) =>
              m.id === event.messageId
                ? { ...m, text: m.text + event.delta }
                : m,
            ),
          )
        },
        onRunErrorEvent({ event }) {
          setMessages((prev) => [
            ...prev,
            {
              id: crypto.randomUUID(),
              role: "assistant",
              text: `Error: ${event.message}`,
            },
          ])
        },
        onRunFinishedEvent() {
          setRunning(false)
        },
      },
    )
  }, [input, running])
 
  return (
    <div className="mx-auto flex h-screen max-w-2xl flex-col p-4">
      <div className="flex-1 space-y-3 overflow-y-auto">
        {messages.map((m) => (
          <div
            key={m.id}
            className={
              m.role === "user"
                ? "ml-auto max-w-[80%] rounded-lg bg-blue-600 p-3 text-white"
                : "mr-auto max-w-[80%] rounded-lg bg-gray-100 p-3"
            }
          >
            {m.text}
          </div>
        ))}
      </div>
 
      <div className="mt-4 flex gap-2">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && send()}
          placeholder="Posez une question à l'agent…"
          className="flex-1 rounded-lg border px-4 py-2"
          disabled={running}
        />
        <button
          onClick={send}
          disabled={running}
          className="rounded-lg bg-blue-600 px-6 py-2 text-white disabled:opacity-50"
        >
          {running ? "…" : "Envoyer"}
        </button>
      </div>
    </div>
  )
}

Montez-le dans app/page.tsx, lancez pnpm dev, et vous obtenez une conversation en flux continu sur le protocole AG-UI en une centaine de lignes.

Le détail important, c'est agent.messages. HttpAgent maintient l'historique de conversation en interne et l'envoie à chaque exécution : vous poussez donc le tour utilisateur dessus avant d'appeler runAgent(). Les tours de l'assistant, eux, sont ajoutés automatiquement au fil du flux.

Étape 8 : État partagé avec les deltas JSON Patch

C'est ici qu'AG-UI cesse d'être « encore un format de streaming » et commence à mériter son nom. Une conversation est unidirectionnelle. Un état partagé ne l'est pas.

Imaginez un agent de recherche qui élabore un plan. Il devrait diffuser ce plan dans une barre latérale à mesure qu'il se forme, marquer les étapes terminées au fur et à mesure de leur exécution, et laisser l'utilisateur réordonner ou supprimer une étape en pleine exécution.

L'agent émet un STATE_SNAPSHOT pour établir la forme, puis des événements STATE_DELTA portant des opérations JSON Patch :

// À l'intérieur de la méthode run() de votre agent
 
emit({
  type: EventType.STATE_SNAPSHOT,
  snapshot: {
    plan: [
      { id: "1", title: "Gather sources", status: "pending" },
      { id: "2", title: "Extract claims", status: "pending" },
      { id: "3", title: "Draft summary", status: "pending" },
    ],
  },
})
 
// Plus tard, quand l'étape 1 se termine — patchez au lieu de tout renvoyer
emit({
  type: EventType.STATE_DELTA,
  delta: [
    { op: "replace", path: "/plan/0/status", value: "complete" },
    {
      op: "add",
      path: "/plan/0/sourceCount",
      value: 12,
    },
  ],
})

Chaque objet du tableau delta est une opération JSON Patch conforme à la RFC 6902. Les opérations supportées sont add, remove, replace, move, copy et test. Envoyer un patch plutôt qu'un instantané signifie qu'un plan de cinquante étapes dont un seul statut change coûte environ 80 octets sur le réseau, au lieu de plusieurs kilooctets.

Côté client, abonnez-vous aux deux :

type PlanStep = { id: string; title: string; status: string }
type AgentState = { plan: PlanStep[] }
 
const [state, setState] = useState<AgentState>({ plan: [] })
 
await agent.runAgent(
  {},
  {
    onStateSnapshotEvent({ event }) {
      setState(event.snapshot as AgentState)
    },
    onStateDeltaEvent({ event }) {
      setState((prev) => applyPatch(structuredClone(prev), event.delta))
    },
  },
)

Utilisez une bibliothèque comme fast-json-patch pour applyPatch plutôt que de l'écrire vous-même — les opérations move et test ont des sémantiques d'ordonnancement subtiles, faciles à manquer.

Les deltas n'ont de sens que si l'état du client ne dérive jamais de celui de l'agent. Si un patch échoue — une opération test renvoie faux, ou un chemin n'existe plus — ne l'ignorez surtout pas en silence. Demandez un nouveau STATE_SNAPSHOT et resynchronisez. Une dérive silencieuse produit des interfaces qui paraissent correctes tout en mentant subtilement.

Puisque l'état est un simple objet reflété des deux côtés, le sens inverse n'est rien d'autre que le corps de la requête suivante. Quand l'utilisateur fait glisser l'étape 3 au-dessus de l'étape 2, mutez l'état local, puis transmettez l'objet mis à jour dans le champ state du prochain appel à runAgent(). L'agent lit input.state et voit la modification de l'utilisateur. Cet aller-retour, c'est ce que « synchronisation d'état bidirectionnelle » veut dire concrètement — sans le moindre WebSocket.

Étape 9 : Générer de l'UI à partir des appels d'outils

Dernière pièce : afficher les appels d'outils comme de l'interface plutôt que comme du texte. Puisque TOOL_CALL_ARGS diffuse les fragments d'arguments, vous pouvez montrer la progression avant même que l'outil soit invoqué.

Donnez un outil à Claude, relayez ses événements de flux, et laissez le frontend décider comment afficher chaque toolCallName :

// Serveur : traduire les blocs tool_use de Claude en événements AG-UI
for await (const chunk of stream) {
  if (
    chunk.type === "content_block_start" &&
    chunk.content_block.type === "tool_use"
  ) {
    emit({
      type: EventType.TOOL_CALL_START,
      toolCallId: chunk.content_block.id,
      toolCallName: chunk.content_block.name,
    })
  }
 
  if (
    chunk.type === "content_block_delta" &&
    chunk.delta.type === "input_json_delta"
  ) {
    emit({
      type: EventType.TOOL_CALL_ARGS,
      toolCallId: currentToolCallId,
      delta: chunk.delta.partial_json,
    })
  }
 
  if (chunk.type === "content_block_stop" && currentToolCallId) {
    emit({ type: EventType.TOOL_CALL_END, toolCallId: currentToolCallId })
  }
}

Puis associez les noms d'outils à des composants côté client :

const TOOL_COMPONENTS: Record<string, React.FC<{ args: string }>> = {
  search_web: SearchCard,
  run_query: QueryCard,
  generate_chart: ChartCard,
}
 
// Dans votre subscriber
onToolCallStartEvent({ event }) {
  setToolCalls((prev) => [
    ...prev,
    { id: event.toolCallId, name: event.toolCallName, args: "" },
  ])
},
onToolCallArgsEvent({ event }) {
  setToolCalls((prev) =>
    prev.map((t) =>
      t.id === event.toolCallId ? { ...t, args: t.args + event.delta } : t,
    ),
  )
},
onToolCallResultEvent({ event }) {
  setToolCalls((prev) =>
    prev.map((t) =>
      t.id === event.toolCallId ? { ...t, result: event.content } : t,
    ),
  )
},

SearchCard reçoit une chaîne JSON partielle. Parsez-la avec indulgence — un utilitaire qui tolère le JSON tronqué vous permet d'afficher « Recherche de AG-UI proto… » pendant que le modèle émet encore ses tokens. C'est ce petit détail qui rend une interface d'agent vivante plutôt que poussive.

Tester votre implémentation

Vérifiez chaque couche indépendamment, plutôt que de déboguer tout le pipeline d'un coup.

Testez le flux d'événements avec curl. Vous devriez voir des trames SSE brutes, une par ligne :

curl -N -X POST http://localhost:3000/api/agent \
  -H "Content-Type: application/json" \
  -d '{
    "threadId": "t1",
    "runId": "r1",
    "messages": [{ "id": "m1", "role": "user", "content": "Say hello" }],
    "state": {},
    "tools": [],
    "context": []
  }'

Le drapeau -N désactive le tampon de curl. Si les trames arrivent toutes ensemble à la fin de l'exécution, vos en-têtes de bufferisation sont erronés — revoyez l'étape 5.

Validez les événements contre le schéma. @ag-ui/core exporte des schémas Zod pour chaque type d'événement. Vérifiez-les dans un test unitaire :

import { EventSchemas } from "@ag-ui/core"
import { describe, expect, it } from "vitest"
 
describe("ClaudeAgent", () => {
  it("emits protocol-valid events in order", async () => {
    const events: BaseEvent[] = []
    await new Promise<void>((resolve) => {
      new ClaudeAgent()
        .run(testInput)
        .subscribe({ next: (e) => events.push(e), complete: resolve })
    })
 
    for (const event of events) {
      expect(() => EventSchemas.parse(event)).not.toThrow()
    }
 
    expect(events.at(0)?.type).toBe(EventType.RUN_STARTED)
    expect(events.at(-1)?.type).toBe(EventType.RUN_FINISHED)
  })
})

Inspectez avec le Dojo AG-UI. Les implémentations de référence sur dojo.ag-ui.com incluent un inspecteur d'événements bruts. Pointez-le vers votre endpoint local pour voir exactement quels événements votre agent émet, et dans quel ordre.

Dépannage

Les événements arrivent en un seul bloc au lieu de se diffuser. Presque toujours un proxy qui met en tampon. Vérifiez la présence de Cache-Control: no-cache, no-transform et de X-Accel-Buffering: no dans la réponse. Sur Vercel, assurez-vous que la route exporte dynamic = "force-dynamic" afin qu'elle ne soit pas optimisée statiquement.

« TEXT_MESSAGE_CONTENT reçu avant TEXT_MESSAGE_START ». Soit votre garde opened est inversée, soit vous émettez des deltas pour un messageId déjà refermé. Chaque messageId ne supporte qu'un seul cycle START/CONTENT/END.

L'exécution ne se termine jamais. Vous avez oublié observer.complete(), ou une exception s'est échappée de la fonction asynchrone à l'intérieur de l'Observable. Enveloppez le corps dans un try/catch et émettez toujours un événement terminal.

Les deltas d'état s'appliquent au mauvais index. Les chemins JSON Patch sont positionnels : /plan/0/status désigne l'élément qui se trouve actuellement en première position. Si l'utilisateur réordonne la liste localement entre deux exécutions, le prochain patch de l'agent visera un autre élément que celui qu'il croyait. Préférez des objets indexés par identifiants stables aux tableaux pour tout ce que l'utilisateur peut réorganiser, ou renvoyez un instantané après un réordonnancement.

Les arguments d'outil arrivent en JSON invalide. C'est normal — les deltas TOOL_CALL_ARGS sont des fragments. Ne parsez qu'après TOOL_CALL_END, ou utilisez un parseur de JSON partiel pour le rendu optimiste.

Erreurs de typage sur observer.next(). L'union d'événements de @ag-ui/core est stricte. Les exemples publiés utilisent as BaseEvent par concision ; en production, écrivez de petites fabriques typées comme runStarted(threadId, runId) pour que le compilateur repère les champs manquants.

Prochaines étapes

Vous avez désormais un agent AG-UI fonctionnel, mais le protocole offre bien d'autres surfaces à explorer :

  • Changez de backend. Remplacez ClaudeAgent par un agent LangGraph, Mastra ou Pydantic AI via leurs adaptateurs AG-UI officiels. Le frontend de l'étape 7 devrait continuer à fonctionner sans la moindre modification — c'est toute la promesse, et cela vaut la peine de vous le prouver.
  • Ajoutez un middleware. La couche de transport d'AG-UI est interchangeable. Insérez un subscriber qui journalise chaque événement vers votre stack d'observabilité, ou un autre qui expurge les données personnelles des deltas TEXT_MESSAGE_CONTENT avant qu'ils ne quittent le serveur.
  • Ajoutez l'humain dans la boucle. Émettez un événement CUSTOM quand l'agent a besoin d'une validation, suspendez l'exécution, puis reprenez avec la décision de l'utilisateur dans input.context.
  • Combinez avec MCP. Faites appeler des serveurs MCP par votre agent AG-UI pour ses outils. Voyez notre guide sur la création d'un serveur MCP en TypeScript — les résultats d'outils alimentent directement les événements TOOL_CALL_RESULT.
  • Comparez avec A2A. AG-UI couvre la relation agent-utilisateur ; A2A couvre la relation agent-agent. Les systèmes de production parlent de plus en plus les deux langues.

Conclusion

AG-UI est un petit protocole qui résout un problème peu glamour : la couche d'interaction entre un agent et la personne qui le regarde travailler. Son pari est qu'une vingtaine d'événements typés — cycle de vie, deltas de texte, appels d'outils, patchs d'état, traces de raisonnement — suffisent à exprimer toute interface d'agent digne d'être construite, et que les normaliser vous libère de changer de framework d'agents sans réécrire votre frontend.

Les pièces sont simples une fois assemblées. AbstractAgent vous donne un endroit où traduire le flux natif de n'importe quel modèle en événements de protocole. Un route handler SSE les achemine. HttpAgent associé à un AgentSubscriber les retransforme en état React. Et les deltas JSON Patch maintiennent un document partagé synchronisé à moindre coût dans les deux sens.

Ce qu'il faut en retenir, c'est la discipline que le protocole impose. Encadrez chaque exécution. Gardez les identifiants de message stables. Émettez un événement terminal sur chaque chemin, y compris en cas d'échec. Ne laissez jamais l'état du client dériver de celui de l'agent sans resynchroniser. Ce sont ces règles qui séparent une interface d'agent qui inspire confiance d'une autre qui se fige mystérieusement dans trois pour cent des exécutions — et AG-UI les rend explicites, au lieu de laisser chaque équipe les redécouvrir à ses dépens.