OpenAI computer_use Action Loop (Batched Actions, Screenshot Submission) — Fix Guide (2026)
computer_use · Action Loop Severity: High

OpenAI computer_use Action Loop (Batched Actions, Screenshot Submission)

The computer_use action loop is deceptively simple in principle — model sends a computer_call, you execute it, send back a screenshot, repeat until done. In practice, three things trip teams up: GA models emit batched actions in one call (preview emitted one), the output shape is a specific computer_call_output item, and you must chain via previous_response_id. Here's the correct loop.

TL;DRLoop: (1) receive Response containing computer_call items, (2) execute all actions[] (GA) or the single action (legacy preview) in order, (3) take a fresh screenshot, (4) send back a computer_call_output item with the screenshot as output, (5) chain via previous_response_id. Repeat until no more computer_call items appear (task done) or you hit a stop condition.

Real error messages you'll see

Model stuck asking for the same action repeatedly
Model stuck asking for the same action repeatedly
# You executed the click, but forgot to send a screenshot back.
# Model has no visible evidence anything happened → re-emits the same action.
# Fix: every computer_call needs a matching computer_call_output with a fresh screenshot.
Missed batched actions on GA model
Missed batched actions on GA model
# Consumer reads item.action (singular) — GA models emit item.actions (plural array).
# Result: model asked for 3 actions (move, click, type), you did only the first.
# Fix: iterate actions[] on GA; fall back to action for preview items.
BadRequestError — wrong output item type
BadRequestError — wrong output item type
openai.BadRequestError: Error code: 400 - {'error': {'message': "Invalid output item type 'tool'. Use 'computer_call_output' for computer_use tool responses.", 'type': 'invalid_request_error'}}
# Sent function_call_output or tool shape instead of computer_call_output.

Action loop event shape

DirectionItem typeKey fields
Server → Clientcomputer_callid, call_id, actions[] (GA) or action (preview), pending_safety_checks[]
Client → Servercomputer_call_outputcall_id, output: {type: "computer_screenshot", image_url: ...}, acknowledged_safety_checks[]
Chainingvia previous_response_idAlternative: conversation parameter
End of task(no computer_call in output)Look for message items; task complete

Root causes (ranked by frequency)

Based on OpenAI developer reports; percentages sum to 101%.

  • 23%
    Missing screenshot in response. Model needs visual evidence after each action; without it, no progress.
  • 18%
    Reading only action (singular) on GA models. GA emits actions[] arrays; iterate them all.
  • 14%
    Wrong output item type. Use computer_call_output, not tool or function_call_output.
  • 12%
    Not chaining via previous_response_id. Each new Response call needs the prior ID or the model loses context.
  • 10%
    Screenshot at wrong resolution. Model reasons in declared dims; screenshot at native dims produces coordinate confusion.
  • 9%
    call_id mismatch. The output's call_id must match the model's call_id from the request; mismatched IDs are dropped silently.
  • 8%
    Executing actions out of order. Batched actions are ordered (move → click → type); executing in random order produces wrong final state.
  • 7%
    No stop condition — infinite loop. Model might not naturally stop. Cap iterations and check for terminal states (message items, timeout).

How to fix it

Fix #1

The correct action loop — execute, screenshot, respond, chain

The canonical pattern with GA batched actions.

For each computer_call from the model: iterate actions[] (GA) or the single action (preview), execute each in order, take a fresh screenshot at declared dimensions, send back a computer_call_output item with the screenshot, and chain the next Response call with previous_response_id.

action_loop.pypython
import base64
import asyncio
from openai import AsyncOpenAI
from playwright.async_api import async_playwright, Page

client = AsyncOpenAI()


MAX_ITERATIONS = 30                    # never let a task exceed this


async def execute_action(page: Page, action: dict, viewport: dict):
    """Execute a single computer_use action in Playwright."""
    t = action.get("type")

    if t == "click":
        button = action.get("button", "left")
        await page.mouse.click(action["x"], action["y"], button=button)

    elif t == "double_click":
        await page.mouse.dblclick(action["x"], action["y"])

    elif t == "move":
        await page.mouse.move(action["x"], action["y"])

    elif t == "drag":
        # GA drag has a path array
        path = action.get("path", [])
        if path:
            await page.mouse.move(path[0]["x"], path[0]["y"])
            await page.mouse.down()
            for pt in path[1:]:
                await page.mouse.move(pt["x"], pt["y"])
            await page.mouse.up()

    elif t == "type":
        await page.keyboard.type(action["text"])

    elif t == "key" or t == "keypress":
        keys = action.get("keys") or [action.get("key")]
        for k in keys:
            await page.keyboard.press(k)

    elif t == "scroll":
        # scroll_x and scroll_y are the deltas
        await page.mouse.wheel(action.get("scroll_x", 0), action.get("scroll_y", 0))

    elif t == "screenshot":
        # No-op — we take a screenshot after every action anyway
        pass

    elif t == "wait":
        await asyncio.sleep(action.get("duration_ms", 1000) / 1000)

    else:
        print(f"unknown action type: {t}")


async def capture_screenshot(page: Page, viewport: dict) -> str:
    """Screenshot the page, return as base64-encoded PNG at declared dims."""
    raw = await page.screenshot(full_page=False)   # visible viewport only

    # Optional: verify dimensions match declared
    # (Retina and DPR quirks may require resizing)
    from PIL import Image
    import io
    img = Image.open(io.BytesIO(raw))
    if img.size != (viewport["width"], viewport["height"]):
        img = img.resize((viewport["width"], viewport["height"]), Image.LANCZOS)
    out = io.BytesIO()
    img.save(out, format="PNG")
    return base64.b64encode(out.getvalue()).decode()


async def computer_use_loop(page: Page, goal: str, viewport: dict = {"width": 1280, "height": 800}):
    """Full action loop until task complete or iteration cap hit."""

    # 1. Initial call with goal + starting screenshot
    screenshot_b64 = await capture_screenshot(page, viewport)

    response = await client.responses.create(
        model="gpt-5.4",
        tools=[{
            "type": "computer_use_preview",
            "display_width": viewport["width"],
            "display_height": viewport["height"],
            "environment": "browser",
        }],
        input=[{
            "role": "user",
            "content": [
                {"type": "input_text", "text": goal},
                {"type": "input_image", "image_url": f"data:image/png;base64,{screenshot_b64}"},
            ],
        }],
        truncation="auto",
    )

    for iteration in range(MAX_ITERATIONS):
        # 2. Extract computer_call items from response.output
        computer_calls = [item for item in response.output if item.type == "computer_call"]

        if not computer_calls:
            # 3a. No more actions — task complete
            print(f"task completed at iteration {iteration}")
            final_message = next((item for item in response.output if item.type == "message"), None)
            if final_message:
                for part in final_message.content:
                    if part.type == "output_text":
                        return part.text
            return "(no final message)"

        # 3b. Process each computer_call
        output_items = []
        for call in computer_calls:
            # GA: iterate actions[]; preview: single action
            actions = call.actions if hasattr(call, "actions") and call.actions else [call.action]

            for action_obj in actions:
                # action may be a Pydantic model — convert to dict
                action = action_obj if isinstance(action_obj, dict) else action_obj.model_dump()
                await execute_action(page, action, viewport)

            # Take fresh screenshot AFTER all batched actions
            await page.wait_for_load_state("domcontentloaded", timeout=5_000)
            screenshot_b64 = await capture_screenshot(page, viewport)

            # 4. Build computer_call_output item
            output_items.append({
                "type": "computer_call_output",
                "call_id": call.call_id,
                "output": {
                    "type": "computer_screenshot",
                    "image_url": f"data:image/png;base64,{screenshot_b64}",
                },
                # acknowledged_safety_checks (see #157) added if needed
            })

        # 5. Chain to next Response with previous_response_id
        response = await client.responses.create(
            model="gpt-5.4",
            previous_response_id=response.id,
            tools=[{
                "type": "computer_use_preview",
                "display_width": viewport["width"],
                "display_height": viewport["height"],
                "environment": "browser",
            }],
            input=output_items,
            truncation="auto",
        )

    raise RuntimeError(f"hit MAX_ITERATIONS={MAX_ITERATIONS} without task completion")


# ✅ Full run
async def main():
    p = await async_playwright().start()
    browser = await p.chromium.launch(headless=False)
    context = await browser.new_context(viewport={"width": 1280, "height": 800})
    page = await context.new_page()
    await page.goto("https://example.com")

    result = await computer_use_loop(
        page,
        goal="Find the \"About\" link, click it, and report the first paragraph of the page.",
    )
    print("Final result:", result)

    await browser.close()
    await p.stop()


if __name__ == "__main__":
    asyncio.run(main())
Note: The await page.wait_for_load_state between action execution and screenshot is critical — screenshots taken during navigation or loading catch a blank or partial page, and the model wastes turns figuring out what happened. Wait for the page to settle before capturing.
Fix #2

Handle GA batched actions AND legacy single-action items

Cross-compatible handler for preview → GA migration.

GA models emit actions[] arrays in a single computer_call (e.g. move + click + type in one call, atomically). Legacy preview emitted one action per call. Consumers that read only action silently drop the extras. Handler needs to try both shapes.

batched_action_compat.pypython
from typing import Any


def extract_actions(computer_call) -> list[dict]:
    """Return list of action dicts, whether the call is GA (batched) or preview (single).
    Works with SDK model objects and raw dicts."""

    def to_dict(x):
        if isinstance(x, dict):
            return x
        if hasattr(x, "model_dump"):
            return x.model_dump()
        return dict(x)

    call = to_dict(computer_call) if not isinstance(computer_call, dict) else computer_call

    # GA: actions is an array
    if "actions" in call and call["actions"]:
        return [to_dict(a) for a in call["actions"]]

    # Preview: single action
    if "action" in call and call["action"]:
        return [to_dict(call["action"])]

    return []


# ✅ Example — GA batched call
ga_call = {
    "type": "computer_call",
    "id": "cu_abc123",
    "call_id": "call_xyz789",
    "actions": [
        {"type": "move", "x": 640, "y": 400},
        {"type": "click", "x": 640, "y": 400, "button": "left"},
        {"type": "type", "text": "hello world"},
    ],
    "pending_safety_checks": [],
    "status": "completed",
}
actions = extract_actions(ga_call)
# → [{"type": "move", ...}, {"type": "click", ...}, {"type": "type", ...}]


# ✅ Example — legacy preview call
preview_call = {
    "type": "computer_call",
    "id": "cu_def456",
    "call_id": "call_uvw012",
    "action": {"type": "click", "x": 100, "y": 200, "button": "left"},
    "pending_safety_checks": [],
    "status": "completed",
}
actions = extract_actions(preview_call)
# → [{"type": "click", "x": 100, "y": 200, "button": "left"}]


# ✅ Ordered execution — the order matters (move BEFORE click, click BEFORE type)
async def execute_batched_actions(page, actions: list[dict], between_delay_ms: int = 50):
    """Execute in the order provided. Small delay between actions helps flaky UIs settle."""
    for i, action in enumerate(actions):
        await execute_action(page, action, viewport={"width": 1280, "height": 800})
        if i < len(actions) - 1 and between_delay_ms:
            import asyncio; await asyncio.sleep(between_delay_ms / 1000)


# ✅ Full action-type coverage — every action a model might emit
async def execute_action(page, action: dict, viewport: dict):
    t = action.get("type")

    # Mouse actions
    if t == "click":
        await page.mouse.click(action["x"], action["y"], button=action.get("button", "left"))
    elif t == "double_click":
        await page.mouse.dblclick(action["x"], action["y"])
    elif t == "right_click":
        await page.mouse.click(action["x"], action["y"], button="right")
    elif t == "move" or t == "mouse_move":
        await page.mouse.move(action["x"], action["y"])
    elif t == "drag":
        path = action.get("path", [])
        if path:
            await page.mouse.move(path[0]["x"], path[0]["y"])
            await page.mouse.down()
            for pt in path[1:]:
                await page.mouse.move(pt["x"], pt["y"])
            await page.mouse.up()

    # Keyboard actions
    elif t == "type":
        await page.keyboard.type(action["text"], delay=action.get("delay", 20))
    elif t == "key" or t == "keypress" or t == "key_press":
        keys = action.get("keys") or ([action.get("key")] if action.get("key") else [])
        for k in keys:
            # Convert model names to Playwright names if needed
            k_norm = normalize_key(k)
            await page.keyboard.press(k_norm)

    # Scrolling
    elif t == "scroll":
        # scroll_x / scroll_y are pixel deltas
        await page.mouse.wheel(action.get("scroll_x", 0), action.get("scroll_y", 0))

    # Waits / no-ops
    elif t == "wait":
        import asyncio; await asyncio.sleep(action.get("duration_ms", 1000) / 1000)
    elif t == "screenshot":
        pass  # we screenshot after the batch anyway

    else:
        print(f"[warn] unknown action type: {t}, action: {action}")


def normalize_key(k: str) -> str:
    """Map model key names to Playwright key names."""
    mapping = {
        "ENTER": "Enter", "RETURN": "Enter",
        "ESC": "Escape", "ESCAPE": "Escape",
        "TAB": "Tab",
        "SPACE": " ",
        "BACKSPACE": "Backspace",
        "DELETE": "Delete",
        "ARROW_UP": "ArrowUp", "ARROW_DOWN": "ArrowDown",
        "ARROW_LEFT": "ArrowLeft", "ARROW_RIGHT": "ArrowRight",
        "HOME": "Home", "END": "End",
        "PAGE_UP": "PageUp", "PAGE_DOWN": "PageDown",
        "CTRL": "Control", "CMD": "Meta", "COMMAND": "Meta",
        "SHIFT": "Shift", "ALT": "Alt",
    }
    return mapping.get(k.upper(), k)


# ✅ Testing — verify handler works with both shapes
def test_extract_actions():
    ga = {"actions": [{"type": "click", "x": 1, "y": 2}]}
    preview = {"action": {"type": "click", "x": 3, "y": 4}}
    empty = {}

    assert len(extract_actions(ga)) == 1
    assert len(extract_actions(preview)) == 1
    assert len(extract_actions(empty)) == 0
    assert extract_actions(ga)[0]["x"] == 1
    assert extract_actions(preview)[0]["x"] == 3
Note: The between_delay_ms of ~50ms between batched actions is a small hedge against flaky UIs — click too fast after mouse-move and some UIs don't register the hover state that made the target visible. If your target UI is well-tested, you can drop this to 0.
Fix #3

Chain via previous_response_id and cap iterations

Prevents infinite loops and lost context.

Every follow-up Response call must include previous_response_id (or use a persistent conversation). Otherwise the model has no memory of prior actions. Also: always cap iterations — the model can occasionally get stuck in loops (click, wait, click, wait...) and needs a stop condition.

loop_control.pypython
import time
from dataclasses import dataclass, field


@dataclass
class LoopState:
    iteration: int = 0
    last_action_hash: str | None = None
    same_action_count: int = 0
    start_time: float = field(default_factory=time.monotonic)

    max_iterations: int = 30
    max_seconds: int = 300
    same_action_threshold: int = 3          # kill loop if same action 3× in a row

    def check_stop_conditions(self, current_actions: list[dict]) -> str | None:
        """Return a stop-reason string, or None to continue."""
        self.iteration += 1

        if self.iteration > self.max_iterations:
            return f"hit max_iterations={self.max_iterations}"

        elapsed = time.monotonic() - self.start_time
        if elapsed > self.max_seconds:
            return f"hit max_seconds={self.max_seconds} (elapsed {elapsed:.0f}s)"

        # Detect action loops
        import hashlib, json
        current_hash = hashlib.sha256(json.dumps(current_actions, sort_keys=True).encode()).hexdigest()
        if current_hash == self.last_action_hash:
            self.same_action_count += 1
            if self.same_action_count >= self.same_action_threshold:
                return f"same action repeated {self.same_action_count} times — likely stuck"
        else:
            self.last_action_hash = current_hash
            self.same_action_count = 1

        return None


async def robust_computer_use_loop(page, goal: str, viewport: dict):
    """Loop with iteration cap, timeout, and stuck-detection."""
    from openai import AsyncOpenAI
    client = AsyncOpenAI()

    state = LoopState(max_iterations=30, max_seconds=300)

    screenshot_b64 = await capture_screenshot(page, viewport)
    response = await client.responses.create(
        model="gpt-5.4",
        tools=[{"type": "computer_use_preview", "display_width": viewport["width"],
                "display_height": viewport["height"], "environment": "browser"}],
        input=[{
            "role": "user",
            "content": [
                {"type": "input_text", "text": goal},
                {"type": "input_image", "image_url": f"data:image/png;base64,{screenshot_b64}"},
            ],
        }],
        truncation="auto",
    )

    while True:
        computer_calls = [item for item in response.output if item.type == "computer_call"]

        if not computer_calls:
            # Task done
            final = next((it for it in response.output if it.type == "message"), None)
            return extract_message_text(final) if final else "(no message)"

        # Check stop conditions BEFORE acting
        all_actions = []
        for call in computer_calls:
            all_actions.extend(extract_actions(call))
        stop_reason = state.check_stop_conditions(all_actions)
        if stop_reason:
            print(f"stopping: {stop_reason}")
            return f"[incomplete] {stop_reason}"

        # Execute
        output_items = []
        for call in computer_calls:
            for action_obj in extract_actions(call):
                await execute_action(page, action_obj, viewport)
            await page.wait_for_load_state("domcontentloaded", timeout=5_000)
            screenshot_b64 = await capture_screenshot(page, viewport)
            output_items.append({
                "type": "computer_call_output",
                "call_id": call.call_id,
                "output": {"type": "computer_screenshot",
                           "image_url": f"data:image/png;base64,{screenshot_b64}"},
            })

        # Chain
        response = await client.responses.create(
            model="gpt-5.4",
            previous_response_id=response.id,
            tools=[{"type": "computer_use_preview", "display_width": viewport["width"],
                    "display_height": viewport["height"], "environment": "browser"}],
            input=output_items,
            truncation="auto",
        )


def extract_message_text(msg_item) -> str:
    if not msg_item:
        return ""
    parts = []
    for part in msg_item.content:
        if part.type == "output_text":
            parts.append(part.text)
    return "".join(parts)


# ✅ Alternative — use Conversations for durable state instead of previous_response_id
async def with_conversation(page, goal: str, viewport: dict, conv_id: str = None):
    """Use a Conversation resource so the loop can be resumed across process restarts."""
    from openai import AsyncOpenAI
    client = AsyncOpenAI()

    if not conv_id:
        conv = await client.conversations.create(metadata={"task": "computer_use"})
        conv_id = conv.id

    # First call attaches to the conversation
    screenshot_b64 = await capture_screenshot(page, viewport)
    response = await client.responses.create(
        model="gpt-5.4",
        conversation=conv_id,                  # ← persistent conversation
        tools=[...],
        input=[{
            "role": "user",
            "content": [
                {"type": "input_text", "text": goal},
                {"type": "input_image", "image_url": f"data:image/png;base64,{screenshot_b64}"},
            ],
        }],
    )
    # ... loop continues, always passing conversation=conv_id
    # If the process dies, restart with the same conv_id — history preserved server-side
    return conv_id


# ✅ Progress reporting during long loops
class ProgressReporter:
    def __init__(self):
        self.actions_executed = 0
        self.screenshots_sent = 0

    def report(self):
        elapsed = 0  # calculate from state
        print(f"  [progress] iteration={self.actions_executed} screenshots={self.screenshots_sent}")
Note: The same_action_hash stuck-detection is a lifesaver for demos. Common stuck pattern: model clicks a button that's covered by a modal it can't see, then clicks again, then again... After 3 identical action-batches, break the loop and report back what the model was doing so a human can diagnose.

Prevention checklist

  • Every computer_call needs a matching computer_call_output with a fresh screenshot. Skip the screenshot and the loop stalls.
  • GA models emit actions[] (batched) — iterate them all in order. Legacy preview emitted action (single).
  • Use computer_call_output, NOT function_call_output or tool. Different item type.
  • Match call_id on the output to the model's call_id from the request. Mismatched IDs drop silently.
  • Chain via previous_response_id (or persistent conversation) — without it, model loses context.
  • Cap iterations (~30) and wall-clock (~300s) and detect stuck loops (same action 3× in a row).
  • Wait for page load state between action execution and screenshot capture.

Frequently asked questions

Why does the model keep asking for screenshots?

Two common causes. First: you're executing actions but not sending screenshots back — model has no visible evidence anything happened. Second: your screenshots are cached/stale (same image URL, or your capture happens before the page updates) — model sees "nothing changed" and asks again. Verify each screenshot is fresh (unique bytes, taken AFTER the last action completed).

Can I skip actions the model requested?

Yes but report it — send back a screenshot with a text message describing what you skipped and why ("I skipped the payment click because policy requires confirmation"). The model then adapts. Silently skipping actions makes the model believe they succeeded and confuses subsequent planning.

What if I use a persistent Conversation instead of previous_response_id?

Works and is often better for long-lived tasks. Attach conversation=conv_id to every Response call; server maintains history automatically. Advantage: process can crash and resume from the same conversation. Disadvantage: slightly more server storage overhead. Don't use BOTH previous_response_id AND conversation in the same call — that's a 400.

Should I execute batched actions sequentially or in parallel?

Always sequentially, in the order emitted. GA batching implies dependent actions (move → click → type must run in that order). Parallel execution produces wrong final state — click before move ends up somewhere else, type before click has no focus target.

How do I know when the task is truly complete?

No computer_call items in the Response output = model considers the task done. Look for a message item with the final answer. Set task text explicitly asking for a summary ("Return the extracted values as a JSON object") so you get a machine-parseable final message rather than free-form prose.

Related errors