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.
By Sana K. · Last updated Aug 14, 2026 · OpenAI · Page #156
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
# 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.
# 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.
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
| Direction | Item type | Key fields |
|---|---|---|
| Server → Client | computer_call | id, call_id, actions[] (GA) or action (preview), pending_safety_checks[] |
| Client → Server | computer_call_output | call_id, output: {type: "computer_screenshot", image_url: ...}, acknowledged_safety_checks[] |
| Chaining | via previous_response_id | Alternative: 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 emitsactions[]arrays; iterate them all. - 14%Wrong output item type. Use
computer_call_output, nottoolorfunction_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
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.
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())
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.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.
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
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.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.
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}")
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_outputwith a fresh screenshot. Skip the screenshot and the loop stalls. - GA models emit
actions[](batched) — iterate them all in order. Legacy preview emittedaction(single). - Use
computer_call_output, NOTfunction_call_outputortool. Different item type. - Match
call_idon the output to the model's call_id from the request. Mismatched IDs drop silently. - Chain via
previous_response_id(or persistentconversation) — 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
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).
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.
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.
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.
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.