
Wiring CopilotKit to a Managed Bedrock AgentCore Harness
A managed AgentCore Harness already owns your model, memory, and tools. Here's how to render its streaming turns in a React chat UI by writing a thin AG-UI adapter that CopilotKit consumes directly — no server-side runtime, no bespoke render path.
Wiring CopilotKit to a Managed Bedrock AgentCore Harness
Most "add a chat UI to your agent" tutorials assume your frontend talks to a model API and that you own the agent loop — prompt assembly, tool dispatch, memory, streaming. But Amazon Bedrock AgentCore offers a managed Harness: a hosted runtime that owns model invocation, conversation memory, and tool execution for you. The browser never touches a model API. You send messages; you get back a streamed, memory-aware, tool-using response.
That's great for the backend — but it leaves a gap on the frontend. CopilotKit 's
<CopilotChat> is a batteries-included React chat component, and most of its guides wire it to a CopilotKit runtime that owns the agent loop. That's not what we have. We have a managed harness that already owns the loop and speaks the Bedrock Converse event-stream format over a SigV4-signed SDK call.The clean way to bridge the two is the AG-UI protocol — an open, event-based contract for streaming agent activity to a UI. CopilotKit speaks AG-UI natively. So the whole integration reduces to one job: translate the harness's Converse event stream into AG-UI events, wrap that in an
AbstractAgent subclass, and hand it to CopilotKit. No server-side runtime, no hand-rolled render loop.This post walks through that adapter end to end.
The shape of the problem
Here's the data path once it's built:
Plain text
<CopilotChat> (React)│ renders / consumes AG-UI events▼HarnessAgent (AbstractAgent subclass — our adapter)│ InvokeHarnessCommand (SigV4-signed)▼Bedrock AgentCore Harness (managed: model + memory + tools)│ binary Converse event stream▲└─ we translate that stream back into AG-UI events
Three pieces do all the work:
- A credential provider so the browser can SigV4-sign the
InvokeHarnesscall.
- A stream translator that maps Converse blocks (
text,toolUse,toolResult) → AG-UI events (TEXT_MESSAGE_*,TOOL_CALL_*).
- An
AbstractAgentsubclass that ties them together and that CopilotKit drives.
Let's build each.
1. Invoking the harness from the browser
A managed harness authorized with AWS_IAM is invoked with a SigV4-signed
InvokeHarnessCommand rather than a bearer token. In a Cognito-backed app, fetchAuthSession() hands you temporary Identity Pool credentials, and the SDK signs each request with them:TypeScript
import { fetchAuthSession } from 'aws-amplify/auth';import { BedrockAgentCoreClient } from '@aws-sdk/client-bedrock-agentcore';function makeClient(): BedrockAgentCoreClient {return new BedrockAgentCoreClient({region: DEPLOYMENT_REGION,credentials: async () => {const session = await fetchAuthSession();const creds = session.credentials;if (!creds) throw new Error('No AWS credentials — sign in first.');return {accessKeyId: creds.accessKeyId,secretAccessKey: creds.secretAccessKey,sessionToken: creds.sessionToken,expiration: creds.expiration,};},});}
The Identity Pool's authenticated role just needs
bedrock-agentcore:InvokeHarness on the harness ARN. Choosing IAM over a custom JWT authorizer is what lets you use the native SDK command — the SDK owns signing, connection timeouts, and retries, and you delete any hand-rolled fetch -plus-event-stream-decoder you might otherwise write.A note on why IAM matters. If your harness is also invoked from server-side callers — a webhook Lambda, a batch job — those callers sign with their own execution-role credentials against the sameInvokeHarnessoperation. One auth model, every caller. I wrote about a webhook-driven version of exactly this in @agent in Your GitHub Issues, No Runner Required .
The invoke body itself is small. The harness loads prior turns from its own memory, so you only really need to send the newest user message — plus optional per-request overrides for system prompt, model, and tools:
TypeScript
new InvokeHarnessCommand({harnessArn: HARNESS_ARN,runtimeSessionId: sessionId, // stable per conversationmessages: harnessMessages, // [{ role, content: [{ text }] }]systemPrompt: systemPromptText ? [{ text: systemPromptText }] : undefined,model: modelId ? { bedrockModelConfig: { modelId } } : undefined,tools, // remote_mcp tool specs (below)});
Because
systemPrompt , model , and tools are first-class fields, you can switch an agent's persona, model, or tool set per request — no redeploy. Passing them as real override fields (rather than smuggling a system prompt in as message content) also lets the harness apply them correctly.Tools travel in the request
The harness supports remote MCP servers injected right into the invoke call. Each tool spec is a
remote_mcp entry with a URL and optional headers:TypeScript
{type: 'remote_mcp',name: 'my-tool-server',config: {remoteMcp: {url: 'https://…',headers: { Authorization: 'Bearer …' }, // optional},},}
The harness calls the MCP server on demand using exactly those credentials. This is the per-agent tool path: the UI reads the selected agent's server list, resolves any stored OAuth tokens into
Authorization headers, and includes them in the invoke. Nothing about tools is baked into the deployment.2. Translating the Converse stream into AG-UI events
This is the heart of the integration. The harness returns a binary AWS event stream (Smithy protocol) that the SDK decodes into a typed async iterable (
response.stream ). It's block-oriented: each content block has a stable contentBlockIndex , is opened by contentBlockStart , streamed via contentBlockDelta , and closed by contentBlockStop . The blocks we care about:Converse block | AG-UI events |
|---|---|
text delta | TEXT_MESSAGE_START → TEXT_MESSAGE_CONTENT (×N) → TEXT_MESSAGE_END |
toolUse | TOOL_CALL_START → TOOL_CALL_ARGS (×N) → TOOL_CALL_END |
toolResult | TOOL_CALL_RESULT |
The subtlety: text blocks carry no
start event — so you open the AG-UI text message lazily on the first text delta. toolUse and toolResult blocks announce their ids in start , so you key them by contentBlockIndex to attribute later deltas to the right call. That means carrying a little mutable state across the stream:TypeScript
export interface HarnessStreamState {turnMessageId: string | null; // groups one assistant turntextStarted: boolean; // has TEXT_MESSAGE_START fired?toolBlocks: Map<number, string>; // blockIndex → toolCallIdresultBlocks: Map<number, { toolCallId: string; parts: string[] }>;}
The translator is a pure function:
(event, state, genId) → BaseEvent[] . Keeping it pure (and injecting the id generator) makes it trivial to unit-test with deterministic ids. The core cases:TypeScript
export function translateHarnessStreamEvent(event: HarnessStreamEvent,state: HarnessStreamState,genId: () => string,): BaseEvent[] {const out: BaseEvent[] = [];// New assistant turn → reset message grouping.if (event.messageStart?.role === 'assistant') {endText(state, out);state.turnMessageId = genId();state.textStarted = false;return out;}// A tool call is announced with its id up front.if (event.contentBlockStart?.start?.toolUse) {endText(state, out); // text → tool activity reads cleanlyconst idx = event.contentBlockStart.contentBlockIndex ?? 0;const toolCallId = event.contentBlockStart.start.toolUse.toolUseId ?? genId();state.toolBlocks.set(idx, toolCallId);out.push({type: EventType.TOOL_CALL_START,toolCallId,toolCallName: event.contentBlockStart.start.toolUse.name ?? 'tool',parentMessageId: state.turnMessageId ?? undefined,} as BaseEvent);return out;}// Deltas: text streams the assistant bubble; toolUse streams JSON args.if (event.contentBlockDelta) {const idx = event.contentBlockDelta.contentBlockIndex ?? 0;const delta = event.contentBlockDelta.delta;if (delta?.text) {if (!state.turnMessageId) state.turnMessageId = genId();if (!state.textStarted) {out.push({ type: EventType.TEXT_MESSAGE_START, messageId: state.turnMessageId, role: 'assistant' } as BaseEvent);state.textStarted = true;}out.push({ type: EventType.TEXT_MESSAGE_CONTENT, messageId: state.turnMessageId, delta: delta.text } as BaseEvent);} else if (delta?.toolUse) {const toolCallId = state.toolBlocks.get(idx);if (toolCallId && delta.toolUse.input) {out.push({ type: EventType.TOOL_CALL_ARGS, toolCallId, delta: delta.toolUse.input } as BaseEvent);}}return out;}// Block close → emit TOOL_CALL_END / TOOL_CALL_RESULT / close text.// …return out;}
Two details worth calling out, because skipping either produces a subtly broken transcript:
- End the in-progress text before a tool call. A
toolUseblock interrupts assistant text. EmitTEXT_MESSAGE_ENDfirst so the transcript reads text → tool card → text, not a mangled interleave.
- Flush at end of stream. If the harness omits a trailing stop event, close any open text message and emit
TOOL_CALL_ENDfor still-open tool blocks. AfinalizeHarnessStream(state)helper keeps the AG-UI event log well-formed no matter how the stream terminates.
3. The AbstractAgent subclass CopilotKit drives
CopilotKit's v2 API lets you register self-managed agents — you own the transport, CopilotKit owns the rendering. You implement
run() (live turns) by returning an RxJS Observable<BaseEvent> that emits the AG-UI events your translator produces:TypeScript
import { AbstractAgent, EventType, type RunAgentInput, type BaseEvent } from '@ag-ui/client';import { Observable } from 'rxjs';export class HarnessAgent extends AbstractAgent {run(input: RunAgentInput): Observable<BaseEvent> {const sessionId = input.threadId || this.threadId || crypto.randomUUID();return new Observable<BaseEvent>((subscriber) => {const abort = new AbortController();(async () => {const runId = input.runId || crypto.randomUUID();subscriber.next({ type: EventType.RUN_STARTED, threadId: sessionId, runId } as BaseEvent);const response = await this.client.send(new InvokeHarnessCommand({ /* …as above… */ }),{ abortSignal: abort.signal },);const state = createHarnessStreamState();for await (const event of response.stream ?? []) {for (const aguiEvent of translateHarnessStreamEvent(event, state, () => crypto.randomUUID())) {subscriber.next(aguiEvent);}}for (const e of finalizeHarnessStream(state)) subscriber.next(e);subscriber.next({ type: EventType.RUN_FINISHED, threadId: sessionId, runId } as BaseEvent);subscriber.complete();})();return () => abort.abort(); // unsubscribe cancels the invoke});}}
Mounting it is three lines. Register the agent on the provider and point
<CopilotChat> at it by id:TSX
const harnessAgent = useMemo(() => new HarnessAgent({ threadId: sessionId, getConfig: () => agentConfigRef.current }),[sessionId],);const agentsMap = useMemo(() => ({ default: harnessAgent }), [harnessAgent]);return (<CopilotKitProvider selfManagedAgents={agentsMap}><CopilotChat agentId="default" threadId={sessionId} /></CopilotKitProvider>);
Note the
getConfig callback passed to the agent. The chat reads the currently selected agent's system prompt, model, and MCP servers from a ref that's updated on every render — so switching agents mid-conversation takes effect on the next turn without recreating the HarnessAgent (which would drop the connection).Rendering tool calls
Out of the box, CopilotKit renders nothing for a tool call unless you register a renderer —
renderToolCall returns null and both the call and its result become empty bubbles. That's very visible in a tool-heavy transcript. Register a wildcard renderer once and every tool call gets a card:TSX
import { useDefaultRenderTool } from '@copilotkit/react-core/v2';export function ToolCallRenderer() {useDefaultRenderTool({render: ({ name, parameters, status, result }) => (<ToolCallCard name={name} parameters={parameters} status={status} result={result} />),});return null; // side-effect only}
Drop
<ToolCallRenderer /> inside the provider and your toolUse / toolResult blocks render as collapsible cards showing the tool name, arguments, and result.History comes for free (almost)
Because the harness persists every turn to AgentCore Memory keyed by session, resuming a conversation is just "load the session's events and hand CopilotKit a snapshot."
AbstractAgent has a connect() hook that fires when <CopilotChat> mounts with an explicit threadId . Fetch the stored events, map them to AG-UI Message[] , and emit a single MESSAGES_SNAPSHOT :TypeScript
protected connect(input: RunAgentInput): Observable<BaseEvent> {return new Observable((subscriber) => {(async () => {const messages = await loadHistory(input.threadId ?? this.threadId);subscriber.next({ type: EventType.MESSAGES_SNAPSHOT, messages } as BaseEvent);subscriber.complete();})();return () => {};});}
The key design choice: make the CopilotKit
threadId be the AgentCore session id. Then live streaming ( run() ) and history restore ( connect() ) share one identifier — no merge dance between "the live thread" and "the stored thread." The one wrinkle is parsing: stored Converse blocks should be mapped to AG-UI messages in exactly one place (a converse-to-agui.ts module), so live and restored transcripts render identically. Parsing the same payload twice — once server-side, once client-side — is how you end up inventing non-standard message shapes that render differently on reload than they did live.If turns can be written to a session after it's loaded — say a webhook run on the same session, or a second browser tab —
connect() alone won't show them, since it only fires on mount. A small polling hook that re-fetches history and calls setMessages() when the count grows closes that gap and makes externally-written turns appear live. (Guard it: never apply while a local turn is streaming, and only ever grow the transcript, so a persistence lag can't wipe what the user is looking at.)Takeaways
- A managed AgentCore Harness owns the hard parts — model, memory, tool execution. Your frontend's only job is transport + translation.
- AG-UI is the bridge. CopilotKit speaks it natively, so you never stand up a server-side runtime — you write an
AbstractAgentthat emits AG-UI events.
- The translation from Bedrock Converse blocks → AG-UI events is the real work, and it's a pure, testable function. Handle the block-oriented streaming carefully (lazy text start, index-keyed tool blocks, end-of-stream flush).
- AWS_IAM auth lets browser and server-side callers share one native
InvokeHarnessoperation and lets the SDK own signing/retries/timeouts.
- Make the chat
threadIdthe AgentCore session id and parse stored history in exactly one place — then live and restored transcripts are the same code path.
Related reading
- @agent in Your GitHub Issues, No Runner Required: Webhooks + Step Functions + Bedrock AgentCore — invoking the same kind of harness from a server-side webhook path.
- Watching Claude Code Work: Streaming Agent Telemetry to CloudWatch and Rebuilding the Conversation — a companion take on reconstructing an agent's turns from a stream.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article