AWS Builder Center
Building Human-in-the-Loop (HITL) AI Agents with Strands Agents, AG-UI, and CopilotKit on Amazon Bedrock AgentCore

Building Human-in-the-Loop (HITL) AI Agents with Strands Agents, AG-UI, and CopilotKit on Amazon Bedrock AgentCore

Learn how to build a human-in-the-loop AI agent on Amazon Bedrock AgentCore Runtime using the Strands Agents SDK, AG-UI protocol, and CopilotKit v2 for real-time expense approval workflows. This step-by-step guide covers frontend tool patterns, AG-UI SSE streaming, and deploying HITL agents with the AgentCore starter toolkit.

AI agents are increasingly capable of handling complex tasks autonomously. But some decisions — approving a large expense, escalating a support ticket, authorizing a data deletion — still need a human in the loop. The challenge is building that pause-and-resume flow cleanly, especially when your agent runs on server-less infrastructure.

Prerequisites

  • An AWS account with access to Amazon Bedrock (Claude Sonnet) and AgentCore Runtime
  • Python 3.12+—  ag-ui-strands 
  • Node.js 20+ — for the CopilotKit Runtime and React frontend
  • Familiarity with these tools (brief descriptions below):
  •  Strands Agents SDK  — an open-source, model-driven framework for building AI agents. You define a system prompt and tools; the LLM handles the reasoning loop.
  •  AG-UI  — an open, event-based protocol that standardizes how AI agents communicate with frontends via Server-Sent Events (SSE). Think of it as a shared language between your agent backend and your UI.
  •   ag-ui-strands   — an adapter that wraps a Strands agent and translates its streaming events into AG-UI SSE events. It also forwards frontend tool calls (like our approval card) from the browser to the agent.
  •  CopilotKit  — an open-source React library that consumes AG-UI events and renders them as a chat UI. It provides hooks like  useHumanInTheLoop  for building HITL approval flows with custom React components.
  •  Amazon Bedrock AgentCore Runtime  — a serverless hosting environment that runs your agent inside isolated microVMs. It supports the AG-UI protocol natively, handling authentication, session isolation, and auto-scaling.
In this post, I’ll walk you through building a HITL expense approval agent using the  Strands Agents , the  AG-UI protocol  (ag-ui-strands) for standardized agent-to-UI communication, and  CopilotKit v2 ’s useHumanInTheLoop hook for the approval flow — all deployed on  Amazon Bedrock AgentCore Runtime .

The Concept

Build an expense approval agent where: - Expenses under $500 are auto-approved - Expenses ≥ $500 pause for manager approval via an interactive card in the chat
Here’s the flow:
  1. User submits an expense via CopilotKit’s chat UI
  1. The Strands agent (running on AgentCore Runtime) categorizes the expense and determines the amount
  1. For high-value expenses, the agent calls approve_expense — a frontend tool registered via CopilotKit’s useHumanInTheLoop
  1. ag-ui-strands forwards the tool call to the browser via AG-UI events
  1. CopilotKit renders an approval card with Approve/Reject buttons
  1. The manager clicks a button, sending a response string back to the agent
  1. The agent receives the tool result and proceeds or informs the user of the rejection
Instead of building custom server-side interrupt machinery, CopilotKit’s frontend tool pattern is used. The agent calls a tool, the tool executes in the browser (rendering UI and waiting for user input), and the result flows back. The AG-UI protocol handles all the plumbing.

Expense Processing Flows

Scenario
Amount
Approval Required?
Flow
Low-value expense
< $500
No
User ==> Agent ==> process_expense ==> Done
High-value expense (Approved)
>= $500
Yes
User ==> Agent ==> process_expense ==> Manager Approves ==> Agent Confirms ==> Done
High-value expense (Rejected)
>= $500
Yes
User ==> Agent ==> process_expense ==> Manager Rejects ==> Agent Confirms ==> Done

Architecture

Human-in-the-loop Architecture

Here’s what each layer does:
  • React frontend: CopilotKit v2’s chat UI with useHumanInTheLoop for the approval card.
  • CopilotKit Runtime: A Node.js server that bridges CopilotKit’s JSON-RPC protocol to AG-UI SSE. CopilotKit’s frontend can’t talk directly to an AG-UI endpoint — this bridge is required.
  • FastAPI proxy: Proxies the AG-UI SSE stream to  AgentCore Runtime , handling AWS request signing. This backend proxy layer is the standard enterprise pattern for AgentCore — it keeps AWS credentials server-side, validates user identity, and provides a natural place for rate limiting, audit logging, and request validation in production.
  • AgentCore Runtime: Runs the Strands agent inside an isolated microVM using the AGUI protocol. Handles session isolation and scaling.
The CopilotKit Runtime bridges CopilotKit’s JSON-RPC protocol to AG-UI SSE, pointing to the FastAPI proxy.
nodejs
import { CopilotRuntime, ExperimentalEmptyAdapter, copilotRuntimeNodeHttpEndpoint } from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL; // Points to your FastAPI proxy
const runtime = new CopilotRuntime({
agents: {
expense_agent: new HttpAgent({ url: AGENT_URL }),
},
});
The AGENT_URL points to the FastAPI proxy, which forwards requests to AgentCore Runtime.
The FastAPI proxy forwards requests to AgentCore Runtime. Since AgentCore is an AWS API, requests need to be signed with AWS credentials:
Python
from botocore.session import Session as BotoSession
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest

# Build the invocations URL
parsed = urlparse(AGENTCORE_ENDPOINT)
invocations_path = parsed.path.rstrip('/') + '/invocations'
endpoint_url = urlunparse(parsed._replace(path=invocations_path))

# Sign with SigV4
boto_session = BotoSession()
credentials = boto_session.get_credentials().get_frozen_credentials()
aws_request = AWSRequest(
method="POST", url=endpoint_url, data=body_bytes,
headers={"Content-Type": "application/json", "Accept": "text/event-stream"},
)
SigV4Auth(credentials, "bedrock-agentcore", "us-east-1").add_auth(aws_request)
The AGENTCORE_ENDPOINT includes the accountId query parameter:
https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/{runtime-id}?accountId={account-id}


The Frontend Tool Pattern

CopilotKit v2 introduces  useHumanInTheLoop  — a hook that registers a frontend tool which pauses agent execution until the user responds. This is the native AG-UI approach to HITL:
TypeScript
import { useHumanInTheLoop, ToolCallStatus } from "@copilotkit/react-core/v2";
import { z } from "zod";

function ExpenseApproval() {
useHumanInTheLoop({
name: "approve_expense",
description: "Request manager approval for expenses $500 or more",
parameters: z.object({
description: z.string(),
amount: z.number(),
category: z.string(),
}),
render: ({ args, status, respond, result }) => {
if (status === ToolCallStatus.Executing && respond) {
return (
<div className="approval-card">
<h3>Manager Approval Required</h3>
<p>Description: {args.description}</p>
<p>Amount: ${args.amount?.toFixed(2)}</p>
<p>Category: {args.category}</p>
<button onClick={() => respond(
"APPROVED: Manager approved this expense. Proceed with process_expense."
)}>Approve</button>
<button onClick={() => respond(
"REJECTED: Manager rejected this expense. Do NOT call process_expense."
)}>Reject</button>
</div>
);
}
if (status === ToolCallStatus.Complete && result) {
const isApproved = typeof result === "string" && result.startsWith("APPROVED");
return <p>{isApproved ? "✅ Approved" : "❌ Rejected"}: {args.description}</p>;
}
return null;
},
}, []);
return null;
}

The suggested approach is for respond() to send a plain string, not a JSON object. The string becomes the tool result that the LLM sees. Using clear prefixes like "APPROVED:" and "REJECTED:" ensures the agent can reliably parse the decision. JSON objects like { approved: false } are unreliable — the LLM may not interpret the boolean correctly.

The Agent: System Prompt as Policy

The approval logic in the agent’s system prompt:
plaintext
SYSTEM_PROMPT = """You are an expense processing agent. When a user submits an expense:
1. Determine the category from: travel, meals, supplies, equipment, or other
2. Extract the description and dollar amount
3. If the amount is $500 or more, call approve_expense FIRST
4. If under $500, skip approval and go directly to step 6
5. CRITICAL: After calling approve_expense, read the tool result:
   - If it starts with "APPROVED", proceed to step 6
   - If it starts with "REJECTED", STOP. Do NOT call process_expense.
6. Only if approved: Use process_expense with the description, amount, and category
7. Report the result

IMPORTANT: You must NEVER call process_expense after a rejection."""
The approve_expense tool is registered by CopilotKit on the frontend and forwarded to the agent as a proxy tool by ag-ui-strands. The agent sees it as a regular tool in its tool list.
A system-prompt-driven policy is simple and transparent. For production, a server-side hook that enforces the threshold programmatically as a safety net.

Handling Frontend Tool Results

ag-ui-strands doesn’t pass frontend tool results back to the agent — it substitutes a generic success message. A state_context_builder is used to extract the actual approval/rejection response and the original expense details from the message history:
Python
from ag_ui_strands import StrandsAgent
from ag_ui_strands.config import StrandsAgentConfig

def _context_builder(run_input, default_msg):
if not run_input.messages:
return default_msg
last = run_input.messages[-1]
if last.role == "tool" and last.content:
content = last.content if isinstance(last.content, str) else str(last.content)
expense_summary = ""
for msg in run_input.messages:
if msg.role == "user" and msg.content and isinstance(msg.content, str):
expense_summary = msg.content
if content.startswith("APPROVED"):
return (
f"The manager approved the expense and it has already been processed. "
f"The original request was: '{expense_summary}'. Confirm to the user briefly."
)
if content.startswith("REJECTED"):
return (
f"REJECTED: Manager rejected this expense. Do NOT call process_expense. "
f"The original request was: '{expense_summary}'. "
f"Inform the user their expense was rejected."
)
return default_msg

config = StrandsAgentConfig(state_context_builder=_context_builder)
agui_agent = StrandsAgent(agent=agent, name="expense_agent", config=config)

Including the original expense details is important — on AgentCore Runtime, each run may hit a fresh microVM with no conversation memory, so the agent needs the full context in the message itself.

Deploying to AgentCore Runtime

The agent entrypoint uses create_strands_app from  ag-ui-strands , which creates a FastAPI server with /invocations and /ping endpoints:
Python
from ag_ui_strands import StrandsAgent, create_strands_app
from ag_ui_strands.config import StrandsAgentConfig
from agent.agent import create_agent

agent = create_agent()
config = StrandsAgentConfig(state_context_builder=_context_builder)
agui_agent = StrandsAgent(
agent=agent,
name="expense_agent",
description="Expense processing agent with HITL approval",
config=config,
)
app = create_strands_app(agui_agent, path="/invocations", ping_path="/ping")

Deploy to AgentCore Runtime using the AGUI protocol ( supported since March 2026 ):
plaintext
agentcore configure -e backend/entrypoint.py --protocol AGUI
agentcore deploy
AgentCore Runtime proxies requests to your container on port 8080, handles authentication, session isolation, and auto-scaling. Each session runs in a dedicated microVM with its own kernel, memory, and filesystem.
The frontend connects via the CopilotKit Runtime:
XML
<CopilotKit runtimeUrl="/copilotkit" agent="expense_agent">
<ExpenseApproval />
<CopilotChat labels={{ title: "Expense Agent" }} className="chat-panel" />
</CopilotKit>


Some Limitations

Prompt-Driven Policy

The $500 threshold is enforced by the system prompt, not programmatically. A sufficiently creative prompt injection could bypass it. For production, I’d add a server-side hook as a safety net that validates the amount before process_expense executes.

No Platform Level Approval Tracking

As of the time of this post, AgentCore Runtime has no concept of “pending approvals” at the platform level. There’s no built-in dashboard, timeout escalation, or audit trail for HITL decisions. Your application is responsible for tracking these if needed.

Real-time HITL Assumption

This architecture assumes the approver is available in the same browser session, in near real-time. The frontend tool pattern (useHumanInTheLoop) requires the approval card to be rendered in an active browser tab — the manager clicks Approve/Reject while the CopilotKit session is alive.
If approval takes hours or days, this approach breaks: the browser tab closes, the SSE connection dies, and there’s no durable record of the pending approval. For async multi-day HITL, you’d need a fundamentally different architecture: a shared database ( AgentCore Memory  or  DynamoDB ) for expense records accessible by both employee and manager sessions, separate dashboard UIs for each role, and an event-driven flow where the agent creates a DB record and returns immediately rather than waiting for approval. AgentCore’s managed session storage doesn’t solve this either — it’s per-session isolated, so the manager’s session can’t see the employee’s session files.

Wrapping Up

The combination of Strands Agents SDK, AG-UI protocol, CopilotKit v2, and Amazon Bedrock AgentCore Runtime gives a clean path to HITL workflows on serverless infrastructure. The frontend tool pattern (useHumanInTheLoop) is the most natural fit for AG-UI today — it handles the pause/resume lifecycle, renders rich approval UI inline in the chat, and works seamlessly with ag-ui-strands and AgentCore Runtime.
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