Return of Control: Let Your Application Execute the Action
Return of control swaps the Lambda executor for your own client, so the agent tells you which tool to run and you run it wherever the code and credentials already live.
By default a Bedrock agent executes actions itself by invoking a Lambda function in your account. Return of control changes that contract: instead of calling anything, the agent stops mid-loop and hands the tool call back to whoever called InvokeAgent. Your application performs the work and sends the result back on the next call. You configure it by replacing the executor: actionGroupExecutor={"customControl": "RETURN_CONTROL"}.
Why you would want this
Lambda is the wrong place to run an action more often than you would think. The system you need to call may live inside a corporate network the agent's Lambda cannot reach. The action may need the end user's credentials or OAuth token, not a service role — you do not want to smuggle a user's bearer token through session attributes into a Lambda. The action may be a client-side effect: navigating a UI, opening a modal, printing a label on a device in front of the user. Or the logic may already exist in your backend and you would rather not fork it into a Lambda that drifts.
The protocol
The flow has three steps and the details matter.
- You call
InvokeAgentnormally withinputText. - Somewhere in the streamed
completion, instead of achunk, you receive areturnControlevent. It carries aninvocationIdand a list ofinvocationInputs. Each input is either afunctionInvocationInput(action group, function name, parameters) or anapiInvocationInput(action group, apiPath, httpMethod, parameters, requestBody), depending on how you defined the action group. - You execute the work, then call
InvokeAgentagain with the samesessionId, passingsessionState.invocationIdechoed back andsessionState.returnControlInvocationResultsholding afunctionResultorapiResult. The agent resumes the same reasoning turn and either calls another tool or produces its answer.
The trap that catches everyone: on that second call you must not send inputText. The call is a continuation, not a new user message, and sending both is a validation error. The invocationId must be the one you were given, and the result must name the same action group and function or API path.
The trade-off
Return of control makes your client stateful in a way the Lambda path does not. Every consumer of the agent now has to implement the loop, handle multiple invocation inputs in one event, and cope with a user closing the browser tab halfway through — the session is left waiting until its idle TTL expires. You also lose the neat property that the agent works identically no matter who calls it: a mobile app, a Slack bot and a batch job would each need their own executor implementation. Lambda executors centralise that; return of control distributes it.
A reasonable pattern is to mix them. Put the safe, server-side lookups behind Lambda action groups, and reserve return of control for the handful of actions that genuinely need the caller's identity or the caller's environment.
Example
import json, boto3
rt = boto3.client("bedrock-agent-runtime")
session_id = "user-8842-thread-3"
def run_locally(fn_name, params):
# Executes in YOUR process, with the end user's token already in scope
if fn_name == "list_open_tickets":
return {"tickets": [{"id": "T-771", "summary": "VPN drops nightly"}]}
raise ValueError(fn_name)
def pump(**invoke_kwargs):
resp = rt.invoke_agent(agentId=AGENT_ID, agentAliasId=ALIAS_ID,
sessionId=session_id, **invoke_kwargs)
answer, pending = "", None
for event in resp["completion"]:
if "chunk" in event:
answer += event["chunk"]["bytes"].decode()
elif "returnControl" in event:
pending = event["returnControl"]
return answer, pending
answer, pending = pump(inputText="Which of my tickets are still open?")
while pending:
results = []
for item in pending["invocationInputs"]:
fi = item["functionInvocationInput"]
params = {p["name"]: p["value"] for p in fi.get("parameters", [])}
out = run_locally(fi["function"], params)
results.append({"functionResult": {
"actionGroup": fi["actionGroup"],
"function": fi["function"],
"responseBody": {"TEXT": {"body": json.dumps(out)}},
}})
# NOTE: no inputText on this call - it is a continuation of the same turn
answer, pending = pump(sessionState={
"invocationId": pending["invocationId"],
"returnControlInvocationResults": results,
})
print(answer)When to use it
- A desktop application whose agent needs to read files or hit an on-premises database that no Lambda in the account can reach.
- A SaaS integration where each action must run under the signed-in user's OAuth token rather than a shared service role.
- A web UI where the agent's chosen action is a front-end effect, such as opening a booking dialog pre-filled with the parameters it extracted.
Discussion