AWS Builder Center
Human-in-the-loop for Amazon Bedrock AgentCore agents in single-page apps

Human-in-the-loop for Amazon Bedrock AgentCore agents in single-page apps

Add human-in-the-loop to Amazon Bedrock AgentCore agents in a React SPA. Pause on an interrupt, keep a session checkpoint, and resume over WebSocket or AG-UI.

Senior Migration and Modernization Specialist SA
A React single-page application (SPA) in front of an agent on Amazon Bedrock AgentCore  Runtime is a common setup, and sooner or later the agent needs something only the user can provide. Which protocol carries the question to the browser? How does the agent stop without losing its place? How does the answer get back, and how does the run resume where it paused instead of starting over?
Human-in-the-loop constructs for agentic workflows in healthcare and life sciences , on the AWS Machine Learning Blog, covers the agent side with four ways to pause an agent on AgentCore Runtime (a hook on tool calls, an interrupt inside the tool, an asynchronous approval through AWS Step Functions , and Model Context Protocol (MCP)  elicitation). It doesn't show the browser code, and the round-trip between the browser and the paused run is rarely explained end to end.
This post fills that gap. It uses the interrupt inside the tool and builds the round-trip twice, first by hand over a WebSocket, then over AG-UI , an open agent-to-user protocol that removes most of the hand-written code. Along the way, you see how AgentCore Runtime isolates sessions, when it reclaims a paused run, and why, in my testing, the browser has to send the first WebSocket frame.
The example is a Know Your Customer (KYC) form filler that asks for five fields one at a time. It's built with the Strands Agents SDK , which uses Amazon Bedrock  as its default model provider, and tested with strands-agents 1.57.1, bedrock-agentcore 1.23.1, and ag-ui-strands 0.4.1. All names and values are fictitious.
The five stages of the solution, from a blocking input() call to AG-UI on AgentCore Runtime
The five stages of the solution, from a blocking input() call to AG-UI on AgentCore Runtime
  1. The agent blocks on input() in a terminal.
  2. The tool raises an interrupt, and the run is saved to a checkpoint.
  3. A WebSocket server carries the question to a browser, but each connection starts a new run.
  4. AgentCore Runtime keys the checkpoint to its session ID, so a reconnect resumes the same run.
  5. AG-UI carries the round-trip over Server-Sent Events (SSE) and keys the checkpoint to its threadId.

What makes a paused agent different

An SPA is stateless between requests. An AgentCore session is stateful and can suspend in the middle of a run. When the KYC agent needs the birth date, it stops, and the run parks at a checkpoint that holds the four fields it already collected. The answer has to reach that same run, not a fresh one that starts the form over.
The human-in-the-loop round-trip between the SPA and the AgentCore session
The human-in-the-loop round-trip between the SPA and the AgentCore session
Figure 2 shows one question-and-answer cycle.
  1. The SPA starts a run.
  2. The agent raises an interrupt, and the run suspends at a checkpoint.
  3. The transport delivers the question to the SPA.
  4. The SPA sends the answer with the ID of the interrupt it belongs to.
  5. The run resumes from the checkpoint and asks for the next field or returns the completed form.

Step 1: Block on input() in a local agent

The simplest version puts the human on the command line.
1
2
3
4
5
6
7
8
9
from strands import Agent, tool

@tool
def request_information_user(question: str, type_of_answer: str) -\> str:
"""Ask the user for a single piece of information and return their answer."""
return input(f"{question} ({type_of_answer}) ")

agent = Agent(tools=[request_information_user])
agent(KYC_SYSTEM_PROMPT)
This works only because the process and the human share one machine. Nothing can hand the question to a remote client, and once the process goes away, the run is gone.

Step 2: Turn the block into an interrupt

Instead of reading input, the tool raises an interrupt. This is the tool context interrupt from the healthcare post. The run parks at a checkpoint and hands the question back to the caller as a reason payload, so the tool no longer knows how the human is reached.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from strands import Agent, ToolContext, tool

HUMAN_INPUT = "human-input"

@tool(context=True)
def request_information_user(
tool_context: ToolContext, question: str, type_of_answer: str
) -\> str:
"""Ask the human for one field by suspending the run until they answer."""
answer = tool_context.interrupt(
HUMAN_INPUT,
reason={"question": question, "type_of_answer": type_of_answer},
)
return answer
The interrupt only pauses the run. If the next request builds a new agent, the answer reaches an empty conversation and the model asks for the first name again. A session checkpoint prevents that. SnapshotSessionManager saves the conversation, the agent state, and the pending interrupt, and building an agent with the same session ID restores them.
1
2
3
4
5
6
7
8
9
10
11
12
13
import os, uuid
from strands.session import SnapshotSessionManager
from strands.storage import LocalFileStorage

SESSION_STORAGE_DIR = os.environ.get("SESSION_STORAGE_DIR", "./sessions/")

def build_agent(session_id: str | None = None) -\> Agent:
session_manager = SnapshotSessionManager(
session_id=session_id or f"hitl-agent-session-{uuid.uuid4().hex}",
storage=LocalFileStorage(SESSION_STORAGE_DIR),
)
return Agent(tools=[request_information_user], session_manager=session_manager,
callback_handler=None)
Setting callback_handler=None stops Strands from printing the model's output to stdout, which on AgentCore Runtime goes to Amazon CloudWatch Logs . The final message repeats every answer, so it shouldn't end up there.
LocalFileStorage keeps the example simple, but its files live on the microVM's disk and disappear when AgentCore reclaims it. In production, swap the backend and keep the rest of the code. Strands includes S3Storage for Amazon Simple Storage Service (Amazon S3) , and for Amazon DynamoDB  you implement the same storage interface yourself.
1
2
3
from strands.storage import S3Storage

storage = S3Storage(bucket="amzn-s3-demo-bucket", prefix="kyc-sessions/")
The caller runs the agent until it suspends, collects the answer, and resumes the same run.
1
2
3
4
5
6
7
8
9
result = agent(KYC_SYSTEM_PROMPT)
while result.stop_reason == "interrupt":
responses = []
for interrupt in result.interrupts:
if interrupt.name == HUMAN_INPUT:
answer = prompt_human(interrupt) # CLI for now
responses.append({"interruptResponse": {
"interruptId": interrupt.id, "response": answer}})
result = agent(responses) # resume, don't restart
One detail cost me real debugging time. The tool must return the value from interrupt(). With a bare return, the model sees an empty result and asks the same question forever.

Choose a transport between the browser and the runtime

After Step 2, the question is data, and AgentCore Runtime offers three ways to carry it. The agent code stays the same.
HTTPSSEWebSocket
EndpointPOST /invocationsPOST /invocations, streamed/ws
How the answer comes backA new request, same session IDA new request, same session IDA frame on the open connection
Streams model outputNoYesYes
Browser authenticationOAuth bearer tokenOAuth bearer tokenAWS Signature Version 4 (SigV4)  pre-signed URL, or an OAuth token in Sec-WebSocket-Protocol
Watch out forNo feedback during long model turnsEventSource only sends GET, so read the stream with fetchYou write the reconnection logic
HTTP is the simplest and the least responsive, SSE adds streaming, and a WebSocket feels the most live but leaves you to keep and restore the connection. Each one still needs the checkpoint, because every HTTP or SSE answer is a new request, and a dropped WebSocket needs it too. This post uses a WebSocket for the hand-built version and SSE for AG-UI.

Step 3: Move the loop to a WebSocket

Only the transport changes. The agent, the tool, and the resume loop are imported unchanged, and the server uses Starlette .
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from starlette.applications import Starlette
from starlette.routing import WebSocketRoute
from hitl_agent_session import HUMAN_INPUT, KYC_SYSTEM_PROMPT, build_agent
from answer_validation import MAX_INVALID_FRAMES, invalid_answer_message, read_answer

async def hitl_endpoint(websocket):
await websocket.accept()
agent = build_agent()
result = await agent.invoke_async(KYC_SYSTEM_PROMPT)
while result.stop_reason == "interrupt":
responses = []
for interrupt in result.interrupts:
if interrupt.name != HUMAN_INPUT:
continue
invalid_frames = 0
while True:
await websocket.send_json({
"type": "question", "interruptId": interrupt.id,
"question": interrupt.reason["question"],
"typeOfAnswer": interrupt.reason["type_of_answer"]})
answer = await read_answer(websocket, interrupt.id)
if answer is not None:
break
invalid_frames += 1
if invalid_frames \>= MAX_INVALID_FRAMES:
return # the socket is closed on exit
await websocket.send_json(invalid_answer_message())
responses.append({"interruptResponse": {
"interruptId": interrupt.id, "response": answer}})
result = await agent.invoke_async(responses)
message = result.message["content"] if result.message else "No message"
await websocket.send_json({"type": "result", "message": message})

app = Starlette(routes=[WebSocketRoute("/ws", hitl_endpoint)])
invoke_async keeps the model call from blocking the event loop. Every client frame is untrusted, so read_answer accepts only a JSON object whose interruptId matches and whose response is a non-empty string of at most 500 characters. Other frames get an error reply, and five invalid frames close the session. The wire protocol has three message types.
1
2
3
server -\> client {"type": "question", "interruptId": "...", "question": "...", "typeOfAnswer": "string"}
client -\> server {"type": "answer", "interruptId": "...", "response": "..."}
server -\> client {"type": "result", "message": "..."}

Step 4: Host the agent on AgentCore Runtime

The Step 3 server runs wherever you host the process yourself, on a laptop or in a container on Amazon Elastic Container Service (Amazon ECS)  or Amazon Elastic Kubernetes Service (Amazon EKS) . AgentCore Runtime instead hosts a container that follows a runtime service contract . The container listens on 0.0.0.0:8080, runs as an ARM64 image, answers GET /ping, and exposes a WebSocket at /ws. The bedrock-agentcore SDK provides that contract.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import os
os.environ.setdefault("SESSION_STORAGE_DIR", "/tmp/sessions") # only /tmp is writable

from bedrock_agentcore import BedrockAgentCoreApp
from hitl_agent_session import HUMAN_INPUT, KYC_SYSTEM_PROMPT, build_agent

app = BedrockAgentCoreApp()

@app.websocket
async def hitl_endpoint(websocket, context):
await websocket.accept()
await websocket.receive_text() # "start" frame, explained below
agent = build_agent(session_id=context.session_id) # key the checkpoint to the session
... # same loop as Step 3

if __name__ == "__main__":
app.run(port=8080)
context.session_id comes from the X-Amzn-Bedrock-AgentCore-Runtime-Session-Id header. Because the snapshot is keyed to it, a client that reconnects with the same session ID lands on the same checkpoint. Snapshots go to /tmp/sessions because the container's working directory is read-only. I deployed the handler as an HTTP-protocol agent, which is correct for a WebSocket agent too, because /ws shares the HTTP container contract.

Send a start frame before the first question

Locally, the Step 3 handler sends the first question and waits. On AgentCore Runtime, the browser connected and received nothing, while CloudWatch Logs showed that the question had already been sent. In my testing, the WebSocket proxy held server frames until the client sent one.
The fix is to read before the first send. The handler waits for a small start frame, which the SPA sends as soon as the socket opens, and each answer then releases the next question.

Connect to the agent on AgentCore Runtime over a WebSocket

A client connects to one endpoint per runtime.
1
wss://bedrock-agentcore.\<region\>.amazonaws.com/runtimes/\<agentRuntimeArn\>/ws
The caller needs the bedrock-agentcore:InvokeAgentRuntimeWithWebSocketStream permission and passes the session ID as a header or query parameter. Get started with bidirectional streaming using WebSocket  lists three ways to authenticate.
  • SigV4 headers sign the handshake with AWS credentials. This suits backends, because browsers can't set custom headers and shouldn't hold AWS credentials.
  • A SigV4 pre-signed URL carries the signature in query parameters, so a plain new WebSocket(url) works. This post uses it.
  • OAuth 2.0 tokens travel base64url-encoded in the Sec-WebSocket-Protocol header from a browser. This needs an OAuth inbound authorizer and is the natural production choice.
The SDK creates the pre-signed URL with the session ID and a 300-second expiry. The browser can't run this code, so the demo adds a local signing server that holds the credentials.
1
2
3
4
5
from bedrock_agentcore.runtime import AgentCoreRuntimeClient

client = AgentCoreRuntimeClient(region=AWS_REGION)
url = client.generate_presigned_url(runtime_arn=AGENT_ARN,
session_id=sid, expires=300)
The hand-built architecture with the browser, the local signing server, and AgentCore Runtime
The hand-built architecture with the browser, the local signing server, and AgentCore Runtime
Figure 3 shows the flow.
  1. The browser calls GET /presign on the signing server and passes a stored session ID to resume.
  2. The server validates the ID and returns a signed URL, never the credentials.
  3. The browser connects to /ws and sends a start frame.
  4. The runtime sends a question frame.
  5. The browser sends an answer frame. Steps 4 and 5 repeat for each field.
  6. The runtime sends a result frame.
Inside the runtime, the agent keeps its checkpoint in /tmp/sessions and calls its model in Amazon Bedrock. The signing server binds to loopback and allows one cross-origin resource sharing (CORS) origin. It's a demo convenience. In production, users sign in through Amazon Cognito  or another OAuth provider.

Handle questions, sessions, and reconnects in the SPA

The React app is small, but each piece is code you maintain.
Transport. Incoming frames are untrusted, so the client narrows each one to a known shape.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function parseServerEvent(raw: unknown): ServerEvent | null {
if (typeof raw !== "object" || raw === null) return null;
const obj = raw as Record\<string, unknown\>;
if (obj.type === "question") {
if (typeof obj.interruptId === "string" &&
typeof obj.question === "string" &&
typeof obj.typeOfAnswer === "string") {
return { type: "question", interruptId: obj.interruptId,
question: obj.question, typeOfAnswer: obj.typeOfAnswer };
}
return null;
}
// "result", "error", and "connected" are narrowed the same way.
// Unknown shapes return null and surface as an error.
return null;
}
Displaying the question. A question frame goes into React state and renders a form with the question, the expected answer type, and one input.
1
2
3
4
5
6
7
8
9
10
11
onQuestion: (q) =\> { setQuestion(q); setAnswer(""); setStatus("question"); },

{status === "question" && question && (
\<form onSubmit={submitAnswer}\>
\<label htmlFor="answer"\>{question.question}\</label\>
\<span\>expected: {question.typeOfAnswer}\</span\>
\<input id="answer" value={answer} autoFocus
onChange={(e) =\> setAnswer(e.target.value)} /\>
\<button type="submit"\>Send answer\</button\>
\</form\>
)}
Submitting sends an answer frame with the same interruptId. The question comes from the model, so the app renders it as plain text and never passes it to dangerouslySetInnerH
The hand-built SPA showing the first KYC question
The hand-built SPA showing the first KYC question
Figure 4 shows the paused run, with the question status badge, the start of the session ID, and the question card. The model wrapped "first name" in Markdown asterisks, and the SPA shows them as typed because it renders plain text.
Sessions and reconnection. The session ID lives in localStorage. After a refresh or a dropped socket, Reconnect & resume fetches a new signed URL for that ID, and the agent continues where it paused.
None of this is hard, but every team writes it a little differently.

Simplify your agent-UI interactions using the AG-UI adapter

The hand-built version works, and I verified it on the deployed runtime, but you own every piece of it, from the wire protocol to the signing server. AG-UI  replaces that plumbing with an open protocol of typed events over SSE, in which the pause is a standard event and the answer is a field on the next request. An AG-UI client from one team can drive an AG-UI agent from another. AgentCore Runtime hosts AG-UI servers directly , and the community ag-ui-strands  adapter wraps the Step 2 tool and prompt without changing them.
1
2
3
4
from hitl_agent_session import (
SESSION_STORAGE_DIR, KYC_SYSTEM_PROMPT, request_information_user,
)
from ag_ui_strands import StrandsAgent, StrandsAgentConfig, create_strands_app
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def _template_agent() -\> Agent:
# The unchanged tool and prompt, with no session manager of its own.
return Agent(tools=[request_information_user], system_prompt=KYC_SYSTEM_PROMPT)

def _session_manager_provider(input_data) -\> SessionManager:
thread_id = getattr(input_data, "thread_id", None)
if not _valid_thread_id(thread_id): # 16-128 chars, [A-Za-z0-9_-]
raise ValueError("invalid or missing AG-UI thread_id")
return SnapshotSessionManager(
session_id=thread_id,
storage=LocalFileStorage(SESSION_STORAGE_DIR),
)

agui_agent = StrandsAgent(
agent=_template_agent(),
name="kyc_agent",
config=StrandsAgentConfig(session_manager_provider=_session_manager_provider),
)
app = create_strands_app(agui_agent, path="/invocations", ping_path="/ping",
origins=[FRONTEND_ORIGIN])
app.add_middleware(RunInputGuard, path="/invocations")
create_strands_app provides the POST /invocations and GET /ping contract. The threadId becomes a file name and each answer becomes a tool result, so RunInputGuard, a small middleware, rejects malformed thread IDs and answers with HTTP 400 before the adapter runs.
The adapter runs the agent, streams events, detects the interrupt, and resumes the run, so you write a wrapper instead of a transport. Two details came from reading its source. The session key moves from the AgentCore session ID to the AG-UI threadId, through a provider that must return a SessionManager. The template agent also needs to have no session manager of its own, or every thread shares one session.

The pause is an event and the answer is a field

A run starts with a POST of RunAgentInput. The SSE stream ends with a RUN_FINISHED event that carries an interrupt outcome in place of the hand-built question frame, with the tool's reason payload inside metadata.
1
2
3
4
5
data: {"type":"RUN_FINISHED","threadId":"kyc-…","runId":"r1",
"outcome":{"type":"interrupt","interrupts":[
{"id":"v1:tool_call:…","reason":"human-input",
"metadata":{"reason":{"question":"What is your first name?",
"type_of_answer":"string"}}}]}}
To answer, you start a new run on the same threadId with no user message and a resume[] array.
1
2
3
{ "threadId": "kyc-…", "messages": [],
"resume": [{ "interrupt_id": "v1:tool_call:…",
"status": "resolved", "payload": "Ada" }] }
The adapter hands the payload back to the parked interrupt, and the run asks for the next field. The hand-built resume loop becomes a field in a request body.

Simplify the SPA with an AG-UI client

The AG-UI SPA uses no UI framework, so you see what the protocol alone removes. It posts RunAgentInput with fetch and reads the SSE response.
1
2
3
4
5
6
7
8
9
10
export function startRun(threadId, callbacks) {
return runOnce({ threadId, messages: [{ role: "user",
content: "Please start the KYC form." }], tools: KYC_TOOLS, … }, callbacks);
}

export function resumeRun(threadId, interruptId, answer, callbacks) {
return runOnce({ threadId, messages: [],
resume: [{ interrupt_id: interruptId, status: "resolved", payload: answer }],
tools: KYC_TOOLS, … }, callbacks);
}
In local development, Vite proxies /invocations to the backend, so there's no signing server, and the client branches on outcome.type === "interrupt". Session persistence is now the threadId in localStorage.
ConcernHand-built (WebSocket)AG-UI
TransportSigned URL, WebSocket, and custom framesfetch POST and an SSE reader
Pause framingInvented {"type":"question"}Protocol RUN_FINISHED interrupt outcome
Answer framingInvented {"type":"answer"}Protocol resume[] field
Resume loopHand-written on the serverInside the adapter
Session persistenceAgentCore session ID through a signed URLAG-UI threadId in localStorage

Render input controls from the answer type

Because the interrupt carries type_of_answer, the AG-UI SPA can replace the text box from Figure 4 with a date picker, a checkbox, or a number input. For the address, it shows a street, city, and country card and joins the parts into the one string the agent expects. The card relies on a keyword match, which breaks if the model rewords the question. To let the agent choose the widget itself, look at A2UI .

What AG-UI doesn't handle for you

Three things stay with you. AG-UI on AgentCore Runtime requires OAuth inbound authentication, for example a CUSTOM_JWT authorizer backed by Amazon Cognito, which is why I verified this version locally only. The ag-ui-strands adapter is community-maintained, so validate it before production. Durable resume still needs Amazon S3 or DynamoDB and a way to notify the user.

How long can the agent wait for the human?

The limits are the same for both versions. On AgentCore Runtime, two lifecycle settings  apply.
  • Idle timeout (idleRuntimeSessionTimeout) defaults to 15 minutes. WebSocket traffic, including ping/pong frames, resets it.
  • Maximum lifetime (maxLifetime) defaults to 8 hours, the maximum on microVMs. Activity doesn't reset it.
A single WebSocket connection also lasts at most 60 minutes , so a longer pause needs a reconnect. When either timer fires, AgentCore replaces the microVM, and the /tmp checkpoint goes with it. To resume an answer given hours later, store the snapshot in Amazon S3 or DynamoDB. Of everything in this post, that change matters most before production.

Security considerations

This sample is a demo. Before you adapt it, treat every KYC answer as personal data and as untrusted model input. Keep answer values out of your logs (the sample sets callback_handler=None for this reason), validate each answer on the server, and consider Amazon Bedrock Guardrails  against prompt injection. Encrypt durable snapshots with AWS Key Management Service (AWS KMS)  and give them a retention period. Treat pre-signed URLs and session IDs as bearer tokens, bind each session to an authenticated user in production, and run the signing server with a role that can invoke only this one runtime.

Conclusion

You built the same pause, ask, and resume round-trip twice. The hand-built version shows the parts you'd otherwise take on faith, such as the start frame and the session ID that ties a reconnect to its checkpoint. The AG-UI version replaces the wire protocol, the resume loop, and most of the client, and leaves the agent unchanged. What's left is your design, meaning how the checkpoint survives, how users sign in, and whether the agent declares its own UI.
If you build the durable path, start by moving the snapshot to Amazon S3 or DynamoDB. I'd like to hear how you handle it.
To learn more, see the following resources.
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