AWS Builder Center
Wiring CopilotKit to a Managed Bedrock AgentCore Harness

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:
  1. A credential provider so the browser can SigV4-sign the  InvokeHarness  call.
  1. A stream translator that maps Converse blocks ( text ,  toolUse ,  toolResult ) → AG-UI events ( TEXT_MESSAGE_* ,  TOOL_CALL_* ).
  1. An  AbstractAgent  subclass 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 same  InvokeHarness  operation. 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 conversation
messages: 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 turn
textStarted: boolean; // has TEXT_MESSAGE_START fired?
toolBlocks: Map<number, string>; // blockIndex → toolCallId
resultBlocks: 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 cleanly
const 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  toolUse  block interrupts assistant text. Emit  TEXT_MESSAGE_END  first 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_END  for still-open tool blocks. A  finalizeHarnessStream(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  AbstractAgent  that 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  InvokeHarness  operation and lets the SDK own signing/retries/timeouts.
  • Make the chat  threadId  the AgentCore session id and parse stored history in exactly one place — then live and restored transcripts are the same code path.

Related reading

Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article