Learn how to debug an agent UI with AG-UI DevTools, a Chrome DevTools panel for AG-UI and LangGraph event streams.
Introducing AG-UI DevTools
When an agent run misbehaves, the Network panel shows you an opaque text/event-stream.
And console.log only shows you what your app thinks happened.
AG-UI DevTools shows you the wire.
It captures, decodes, and validates every event in a run, and it lays them out in the order they arrived.
It also understands LangGraph Platform streams.
If you're using Threadplane 0.3.4 or later, it does even more.
In development, a Threadplane app reports which of its signals each event wrote, which generated components rendered, and it lets the panel script your agent's next run.
Goals
- Install AG-UI DevTools.
- Walk through each tab in the panel.
- Learn what the protocol validator catches.
- Script an approval interrupt with no model call.
- Look at a few practical use cases.
- See how the validator helps if you build your own AG-UI framework.
- Have fun!
Install
To get started, add AG-UI DevTools from the Chrome Web Store.
You'll need Chrome 111 or newer.
Then:
- Open your app at
http://localhost. - Open Chrome DevTools.
- Select the AG-UI panel (it may be behind the
»overflow if your tab strip is full).
Capture is on for localhost, 127.0.0.1, and 0.0.0.0 right away.
For any other origin, click Enable capture in the panel and reload the page.
I should note that the extension stays out of your data.
It makes no network requests of its own, it never reads Authorization headers or cookies, and the capture lives in memory unless you export it.
The source is on GitHub if you want to check.
Timeline
The Timeline tab is where you'll spend most of your time.
At the top is a waterfall of each run, with bars for messages, tool calls, and steps.
Below that is every event in order, and selecting an event shows its payload on the right.
Here is a screenshot of a two-run capture with a TOOL_CALL_START event selected:
Let's break this down:
- Clicking a bar in the waterfall jumps to its event in the list.
- The Filter box narrows the list by text.
- Expand chunks rebuilds the
*_CHUNKevents into their full start, content, and end events. - The Run selector in the top right scopes every tab to a single run.
- For LangGraph Platform streams, the detail pane also lists the AG-UI events the panel derived from each frame.
Issues
This is the feature I think you'll appreciate the most.
As events arrive, a validator checks them against the AG-UI protocol.
An event with a problem gets a verdict card in the detail pane, and the toolbar shows an issue count for the current scope.
Click the count, and the list filters down to just the events with issues.
In this example, a TEXT_MESSAGE_CONTENT event references a message that was never opened:
Here are a few of the rules it checks:
-
event-before-run-startedandevent-after-terminal: events outside the run. -
run-never-terminated: a run that never sentRUN_FINISHEDorRUN_ERROR. -
unopened-message-idandunopened-tool-call-id: content for a message or tool call that was never started. -
unclosed-messageandunclosed-tool-call: a start with no end. -
tool-args-not-json: streamed tool arguments that don't join into valid JSON. -
state-patch-failed: aSTATE_DELTAthat can't be applied. -
shape-invalid: an event that fails the AG-UI schema.
The Network panel can't tell you any of that. 😲
Runs
The Runs tab is a table with one row per run:
You get the thread, agent, outcome, duration, time to first token (TTFT), event count, and issue count.
Missing values say so in words, like "not reported" or "still running", rather than showing a misleading zero.
Click a row to scope the panel to that run and open it in Timeline.
Messages
The Messages tab rebuilds the conversation from the stream:
User turns come from the captured request, and assistant messages, reasoning, and tool calls come from the stream, in time order.
Each tool call gets a verdict on its arguments: streaming, parsed, or failed.
The Timeline buttons jump back to the frames that built each message.
State
If your agent shares state with the UI, the State tab replays it.
Each run gets a scrubber with one frame per STATE_SNAPSHOT or STATE_DELTA:
Each frame shows its JSON Patch operations and the full state document after they're applied.
So when a value in your UI looks wrong, you can scrub back to the exact delta that set it.
UI
The UI tab is for generative UI.
It lists every A2UI and json-render surface in the capture as a component tree, and it badges each node as rendered, fallback, unknown type, or not rendered.
Here is a json-render surface from a Threadplane development build, where the model asked for two chart types the app never registered:
Without a Threadplane app, the panel infers those states from the wire against the A2UI basic catalog.
With a Threadplane development build, the app reports how each spec actually resolved, so the findings are marked EXACT.
I think this one saves a lot of guessing.
An unknown component type renders nothing, along with everything under it, and before this nothing told you why.
Signals
The Signals tab needs a Threadplane 0.3.4+ development build.
A Threadplane agent keeps its state in signals: messages, status, toolCalls, interrupt, and so on.
After handling each event, the agent reports which signals that event wrote.
The tab shows those reports as a matrix, with signals as rows and events as columns:
Select an event column, and it finds that event's frame in Timeline.
So when your spinner never goes away, you can see whether any event ever wrote status back to idle.
Note, the report carries signal names and timing only.
The agent never reads the values it writes, so nothing from the conversation is in it.
You don't need the extension to peek at these, by the way.
They're plain CustomEvents on window:
window.addEventListener('threadplane:devtools', (e) => console.log(e.detail)); Simulate
Simulate scripts your agent's next run.
Your app's real code handles the scripted events (the adapter, any middleware, and your components), and only the network call is replaced.
That means no model call, no backend, and the exact same run every time.
To get started, turn on Developer mode for the site in the panel.
It's a per-site switch, and while it's on, every tab shows a banner.
Then pick a template, choose the AG-UI or LangGraph adapter, and click Arm:
The templates are:
- Interrupt (approval)
- Subagent handoff
- Malformed event
- Replay a captured run
The arm list shows each arm as armed, consumed, expired, cancelled, or rejected.
If no Threadplane hook answers within two seconds, it tells you so.
Simulate needs a Threadplane 0.3.4+ development build, and it only reaches the page's top frame.
The panel isn't doing anything magic here, either.
It dispatches a CustomEvent, and you can do the same from the console.
Here's an approval interrupt for an AG-UI agent:
window.dispatchEvent(new CustomEvent('threadplane:devtools:arm', { detail: { v: 1, armId: 'refund-approval', adapter: 'ag-ui', runs: [ { events: [ { type: 'RUN_STARTED', threadId: 't', runId: 'r1' }, { type: 'TEXT_MESSAGE_START', messageId: 'ai-1', role: 'assistant' }, { type: 'TEXT_MESSAGE_CONTENT', messageId: 'ai-1', delta: 'This needs your approval.' }, { type: 'TEXT_MESSAGE_END', messageId: 'ai-1' }, { type: 'RUN_FINISHED', threadId: 't', runId: 'r1', outcome: { type: 'interrupt', interrupts: [{ id: 'int-1', reason: 'confirmation', message: 'Approve the $42 refund?' }] }, }, ], }, { events: [ { type: 'RUN_STARTED', threadId: 't', runId: 'r2' }, { type: 'TEXT_MESSAGE_START', messageId: 'ai-2', role: 'assistant' }, { type: 'TEXT_MESSAGE_CONTENT', messageId: 'ai-2', delta: 'Refund issued.' }, { type: 'TEXT_MESSAGE_END', messageId: 'ai-2' }, { type: 'RUN_FINISHED', threadId: 't', runId: 'r2', outcome: { type: 'success' } }, ], }, ], }, })); Let's review:
- First, the next run your agent starts takes
runs[0], which pauses on the interrupt, so your approval UI shows up. - Then, when you approve, the resume takes
runs[1]and finishes the turn. -
RUN_STARTEDandRUN_FINISHEDtake the real run'sthreadIdandrunId, so the ids in the script don't need to match anything. - Each step is acknowledged with a
threadplane:devtools:ackevent, and a bad arm is rejected with a short reason.
For a LangGraph agent, set adapter: 'langgraph' and give each run a frames array of { event, data } frames, exactly as the LangGraph SDK yields them.
One more note.
The page can't tell who dispatched an arm, so any script running on your development page can script your agent's next run.
The Developer mode switch guards against accidents, not against code that's already in the page.
Session and Export
The Session tab shows what the panel detected (framework, transport, and protocol), the capture status, issue totals, and the export options:
You can export a capture a few ways:
- Download capture (.agui.jsonl), which you can import back into the panel later.
- Copy JSON to clipboard.
- Download TypeScript fixture (.ts).
- Download Threadplane test (.spec.ts) for LangGraph Platform captures. It replays the capture through
MockAgentTransportfrom@threadplane/langgraph, and it passes as generated.
Before you export, you can redact message text, reasoning, tool arguments, tool results, and state values.
Redacted values become «redacted: 412 chars», so the structure, ordering, sizes, and timings all survive.
Note, nothing is redacted unless you choose it, and the Export button in the toolbar is always unredacted.
Practical use cases
Here are just a few scenarios where AG-UI DevTools can help in your day-to-day work, with code for each one.
Testing approval flows without a model
Approval flows are annoying to test by hand.
You have to coax the model into asking, every time, and each attempt costs a real call.
The good news is that the same threadplane:devtools:arm event works from an end-to-end test, as long as the test runs against a development build (like ng serve).
Let's say your app shows approvals with <chat-approval-card> from @threadplane/chat:
<chat [agent]="agent" /> <chat-approval-card [agent]="agent" (action)="onApproval($event)" /> onApproval(action: ChatApprovalAction): void { void this.agent.submit({ resume: { approved: action === 'approve' } }); } Here is a Playwright test that arms two runs, sends a message, and approves the refund, with no model and no backend.
Create an e2e/approval.spec.ts file:
import { expect, test, type Page } from '@playwright/test'; const refundRuns = [ { events: [ { type: 'RUN_STARTED', threadId: 't', runId: 'r1' }, { type: 'TEXT_MESSAGE_START', messageId: 'ai-1', role: 'assistant' }, { type: 'TEXT_MESSAGE_CONTENT', messageId: 'ai-1', delta: 'This needs your approval.' }, { type: 'TEXT_MESSAGE_END', messageId: 'ai-1' }, { type: 'RUN_FINISHED', threadId: 't', runId: 'r1', outcome: { type: 'interrupt', interrupts: [{ id: 'int-1', reason: 'confirmation', message: 'Approve the $42 refund?' }] }, }, ], }, { events: [ { type: 'RUN_STARTED', threadId: 't', runId: 'r2' }, { type: 'TEXT_MESSAGE_START', messageId: 'ai-2', role: 'assistant' }, { type: 'TEXT_MESSAGE_CONTENT', messageId: 'ai-2', delta: 'Refund issued.' }, { type: 'TEXT_MESSAGE_END', messageId: 'ai-2' }, { type: 'RUN_FINISHED', threadId: 't', runId: 'r2', outcome: { type: 'success' } }, ], }, ]; async function armNextRuns(page: Page, runs: unknown[]): Promise<void> { await page.evaluate((runs) => new Promise<void>((resolve, reject) => { const armId = crypto.randomUUID(); const timer = window.setTimeout(() => { cleanup(); reject(new Error('Timed out waiting for the development agent hooks.')); }, 3000); function cleanup() { window.clearTimeout(timer); window.removeEventListener('threadplane:devtools:ack', onAck); } function onAck(event: Event) { const ack = (event as CustomEvent).detail; if (ack?.armId !== armId) return; cleanup(); if (ack.state === 'armed') resolve(); else reject(new Error(`Script rejected: ${ack.reason ?? ack.state}`)); } window.addEventListener('threadplane:devtools:ack', onAck); window.dispatchEvent(new CustomEvent('threadplane:devtools:arm', { detail: { v: 1, armId, adapter: 'ag-ui', runs }, })); }), runs); } test('approving a refund finishes the turn', async ({ page }) => { await page.goto('/'); const input = page.getByRole('textbox', { name: 'Type a message' }); await expect(input).toBeVisible(); await armNextRuns(page, refundRuns); await input.fill('Refund order 42'); await page.getByRole('button', { name: 'Send message' }).click(); await page.getByRole('button', { name: 'Approve' }).click(); await expect(page.getByText('Refund issued.')).toBeVisible(); }); Let's review:
- First, we wait for the chat input, because the agent registers its listener when it's created.
- Then,
armNextRuns()dispatches the arm and waits for thearmedacknowledgement. A rejected script fails with its reason; missing development hooks fail after three seconds. Both paths remove the listener. - The message takes
runs[0]and pauses on the interrupt, which opens the approval card. - Clicking Approve calls
submit({ resume }), which takesruns[1]and finishes the turn.
Want to test Cancel too?
Arm the same script again and click Cancel instead.
Every arm is used once, so each test gets the exact same run.
Finding a stuck spinner
When the UI says it's working forever, start with the Runs tab.
If the run shows aborted with a run-never-terminated issue, the backend closed the stream without ending the run.
That's a server fix, and there's an example of exactly that fix in the framework section near the end of this post.
If the run finished cleanly, the problem is on the client, and that's where the signals report helps.
Paste this into the console of your development build:
window.addEventListener('threadplane:devtools', (event) => { const report = event.detail; if (report.kind === 'render') return; if (report.wrote.includes('status')) { console.log(report.adapter, report.eventType, report.wrote.join(', ')); } }); For an AG-UI agent, a healthy run logs something like this:
ag-ui RUN_STARTED status, isLoading, error, interrupt, customEvents, activities ag-ui RUN_FINISHED messages, status, isLoading, interruptSession, interrupt If you see RUN_STARTED and never a RUN_FINISHED (or RUN_ERROR), your agent never heard that the run ended.
For a LangGraph agent, look for run:start and run:end instead.
Debugging generative UI
When a generated component doesn't show up, open the UI tab.
An unknown type badge means your registry has no entry for that type, and fallback means the component's props weren't ready yet.
You can get the same answer from the render report in the console:
window.addEventListener('threadplane:devtools', (event) => { const report = event.detail; if (report.kind !== 'render') return; console.table(report.elements.filter((element) => element.state !== 'mounted')); }); Let's say the model asked for a GoogleMap you never registered.
The report shows the map as unresolved, and the pin inside it as hidden:
key type state map GoogleMap unresolved pin Text hidden The fix is to add the component to the catalog you pass to <chat>:
import { withViews } from '@threadplane/render'; import { GoogleMapComponent } from './google-map.component'; export const catalog = withViews(baseCatalog, { GoogleMap: GoogleMapComponent }); <chat [agent]="agent" [views]="catalog" /> withViews() only adds new keys, so it won't replace anything already in your catalog.
Sharing a bug report
When you need to report a protocol bug to someone else, export a redacted capture.
In the Session tab, check Message text and Tool arguments, then click Download capture (.agui.jsonl).
Each line in the file is one record, and the header says exactly what was redacted:
{"kind":"header","schemaVersion":1,"tool":"ag-ui-devtools@0.2.0","capturedAt":"2026-11-02T15:04:05.000Z","url":"http://localhost:4200","transport":"sse","redacted":["text","toolArgs"]} {"kind":"event","connId":"c1","seq":3,"tMs":63,"event":{"type":"TEXT_MESSAGE_CONTENT","messageId":"m_1","delta":"«redacted: 23 chars»"}} {"kind":"event","connId":"c1","seq":7,"tMs":131,"event":{"type":"TOOL_CALL_ARGS","toolCallId":"tc_1","delta":"«redacted: 16 chars»"}} The event ty...








