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.

  1. You call InvokeAgent normally with inputText.
  2. Somewhere in the streamed completion, instead of a chunk, you receive a returnControl event. It carries an invocationId and a list of invocationInputs. Each input is either a functionInvocationInput (action group, function name, parameters) or an apiInvocationInput (action group, apiPath, httpMethod, parameters, requestBody), depending on how you defined the action group.
  3. You execute the work, then call InvokeAgent again with the same sessionId, passing sessionState.invocationId echoed back and sessionState.returnControlInvocationResults holding a functionResult or apiResult. 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

Example · python
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

  • Be the first to comment on this lesson.