Skip to content

Commit 42e269e

Browse files
authored
Add bounded image-only Agent Browser view frames (#84)
1 parent b7368bc commit 42e269e

16 files changed

Lines changed: 268 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ All notable changes to Agent Browser are recorded here. The format follows
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- An observer-authorized, image-only `POST /browser/view-frame` endpoint and matching Python/Node
12+
client methods for a future authenticated RC relay. Capture is limited to one frame per second
13+
per owned session, omits page text and form values, and does not consume model vision steps.
14+
The local noVNC port remains loopback-only and must not be tunneled.
15+
916
## [0.2.2] - 2026-09-04
1017

1118
### Added

‎clients/node/src/index.d.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export type ErrorCode =
3939
| 'SESSION_NOT_FOUND'
4040
| 'SESSION_EXPIRED'
4141
| 'VISION_BUDGET_EXHAUSTED'
42+
| 'VIEW_FRAME_RATE_LIMITED'
4243
| 'INVALID_URL'
4344
| 'DESTINATION_BLOCKED'
4445
| 'INVALID_INTERACTION'
@@ -114,6 +115,15 @@ export interface SnapshotResponse {
114115
vision_steps_remaining: number
115116
}
116117

118+
export interface ViewFrameResponse {
119+
api_version: 'v1'
120+
status: 'view_frame'
121+
session_id: string
122+
screenshot_base64: string
123+
viewport: Viewport
124+
captured_at: string
125+
}
126+
117127
export interface InteractResponse {
118128
api_version: 'v1'
119129
status: 'interacted'
@@ -181,6 +191,7 @@ export declare class Session {
181191

182192
navigate(url: string, options?: RequestOptions): Promise<NavigateResponse>
183193
snapshot(options?: RequestOptions): Promise<SnapshotResponse>
194+
viewFrame(options?: RequestOptions): Promise<ViewFrameResponse>
184195
click(
185196
target: { selector: string; x?: never; y?: never } | { x: number; y: number; selector?: never },
186197
options?: RequestOptions,

‎clients/node/src/index.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ export class Session {
116116
return this.browser._post('/browser/snapshot', { session_id: this.id }, 'observer', options)
117117
}
118118

119+
/** Capture pixels only for a local relay; does not consume model vision budget. */
120+
viewFrame(options = {}) {
121+
return this.browser._post('/browser/view-frame', { session_id: this.id }, 'observer', options)
122+
}
123+
119124
/**
120125
* Click a selector or an x/y point. Exactly one of the two is allowed by the server.
121126
* @param {{ selector?: string, x?: number, y?: number }} target

‎clients/node/test/client.test.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@ test('routes reads to the observer token and writes to the controller token', as
9494
assert.equal(calls[1].headers.authorization, 'Bearer ctl')
9595
})
9696

97+
test('view frames use observer authority and the image-only route', async () => {
98+
const { browser, calls } = client([{ body: CREATED }, { body: { status: 'view_frame' } }], {
99+
observerToken: 'obs',
100+
controllerToken: 'ctl',
101+
})
102+
await (await browser.createSession()).viewFrame()
103+
assert.ok(calls[1].url.endsWith('/browser/view-frame'))
104+
assert.equal(calls[1].headers.authorization, 'Bearer obs')
105+
})
106+
97107
test('falls back to the only token supplied', async () => {
98108
const { browser, calls } = client([{ body: { status: 'ok' } }], { controllerToken: 'ctl' })
99109
await browser.health()

‎clients/python/src/aether_browser/_client.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,12 @@ def snapshot(self, *, timeout: float | None = None) -> dict[str, Any]:
157157
"""Capture bounded page state plus a base64 PNG. Consumes exactly one vision step."""
158158
return self.browser._post("/browser/snapshot", {"session_id": self.id}, "observer", timeout)
159159

160+
def view_frame(self, *, timeout: float | None = None) -> dict[str, Any]:
161+
"""Capture pixels only for a local relay; does not consume model vision budget."""
162+
return self.browser._post(
163+
"/browser/view-frame", {"session_id": self.id}, "observer", timeout
164+
)
165+
160166
def click(
161167
self,
162168
*,

‎clients/python/tests/test_client.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,16 @@ def test_snapshot_is_a_read(self) -> None:
131131
self.assertTrue(calls[1]["url"].endswith("/browser/snapshot"))
132132
self.assertEqual(calls[1]["headers"]["authorization"], "Bearer obs")
133133

134+
def test_view_frame_is_an_observer_read(self) -> None:
135+
browser, calls = client(
136+
[{"body": CREATED}, {"body": {"status": "view_frame"}}],
137+
observer_token=OBSERVER,
138+
controller_token=CONTROLLER,
139+
)
140+
browser.create_session().view_frame()
141+
self.assertTrue(calls[1]["url"].endswith("/browser/view-frame"))
142+
self.assertEqual(calls[1]["headers"]["authorization"], "Bearer obs")
143+
134144
def test_falls_back_to_the_only_token_supplied(self) -> None:
135145
browser, calls = client([{"body": {"status": "ok"}}], controller_token=CONTROLLER)
136146
browser.health()

‎docs/API.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Release acceptance instead consumes the immutable image ID from the preceding ex
4242
| `POST /browser/session/create` | no | yes |
4343
| `POST /browser/navigate` | no | yes |
4444
| `POST /browser/snapshot` | yes | yes |
45+
| `POST /browser/view-frame` | yes | yes |
4546
| `POST /browser/interact` | no | yes |
4647
| `POST /browser/session/end` | no | yes |
4748

@@ -54,6 +55,7 @@ Release acceptance instead consumes the immutable image ID from the preceding ex
5455
- Accessibility snapshot: 500 flattened nodes.
5556
- Encoded PNG screenshot: 14,000,000 base64 characters.
5657
- Vision budget: 1–100 snapshots, default 25.
58+
- Image-only view capture: at most one frame per second per session; 429 includes `Retry-After: 1`.
5759
- Coordinates: 0–4,095; scroll delta: -10,000–10,000.
5860
- Capacity retry guidance: 1–300 seconds.
5961

@@ -69,6 +71,8 @@ Release acceptance instead consumes the immutable image ID from the preceding ex
6971

7072
`POST /browser/snapshot` atomically consumes one vision step and increments the session sequence. It returns bounded structured state, a base64 PNG, viewport metadata, counters, and capture time.
7173

74+
`POST /browser/view-frame` accepts the same owned session ID and returns only a bounded base64 PNG, viewport, and capture time. It does not extract page text, accessibility, URL, or form values and does not consume the model vision budget. Pixels can still show what the human typed; a relay must keep frames out of model context and logs. It is a loopback API for an authenticated relay; it does not make the unauthenticated noVNC port safe to expose. The relay must enforce the remote owner/session grant separately.
75+
7276
## Interaction
7377

7478
Only `click`, `type`, `scroll`, and `press` exist. Click/type require either a selector or an x/y pair, never both. Scroll accepts nonzero bounded deltas. Press accepts only the enumerated keys and combinations; clipboard shortcuts are not allowlisted. Typed text is preserved byte-for-byte after JSON decoding, including leading and trailing whitespace. There is no arbitrary JavaScript, CDP, upload, clipboard, download, extension, shell, filesystem, credential, or cookie field.

‎src/agent_browser/main.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
NavigateResponse,
4040
SnapshotRequest,
4141
SnapshotResponse,
42+
ViewFrameResponse,
4243
)
4344
from agent_browser.runtime import (
4445
BrowserLaunchError,
@@ -56,6 +57,7 @@
5657
SessionExpiredError,
5758
SessionManager,
5859
SessionNotFoundError,
60+
ViewFrameRateLimitedError,
5961
VisionBudgetExhaustedError,
6062
)
6163

@@ -575,6 +577,17 @@ async def snapshot(payload: SnapshotRequest, request: Request) -> SnapshotRespon
575577
vision_steps_remaining=result.vision_steps_remaining,
576578
)
577579

580+
@application.post("/browser/view-frame", response_model=ViewFrameResponse)
581+
async def view_frame(payload: SnapshotRequest, request: Request) -> ViewFrameResponse:
582+
await require(request, RequiredAuthority.OBSERVER)
583+
result = await session_manager.capture_view_frame(payload.session_id)
584+
return ViewFrameResponse(
585+
session_id=result.session_id,
586+
screenshot_base64=result.frame.screenshot_base64,
587+
viewport=result.frame.viewport,
588+
captured_at=result.captured_at,
589+
)
590+
578591
@application.post("/browser/interact", response_model=InteractResponse)
579592
async def interact(payload: InteractRequest, request: Request) -> InteractResponse:
580593
await require(request, RequiredAuthority.CONTROLLER)
@@ -617,6 +630,8 @@ def _known_fault(error: Exception) -> _ApiFault | None:
617630
return _ApiFault(ErrorCode.SESSION_EXPIRED, 410)
618631
if isinstance(error, VisionBudgetExhaustedError):
619632
return _ApiFault(ErrorCode.VISION_BUDGET_EXHAUSTED, 409)
633+
if isinstance(error, ViewFrameRateLimitedError):
634+
return _ApiFault(ErrorCode.VIEW_FRAME_RATE_LIMITED, 429, retry_after_seconds=1)
620635
if isinstance(error, InvalidBrowserInteractionError):
621636
return _ApiFault(ErrorCode.INVALID_INTERACTION, 400)
622637
if isinstance(error, (BrowserLaunchError, BrowserNotReadyError)):
@@ -661,6 +676,7 @@ def _external_security_fault(error: BaseException) -> _ApiFault | None:
661676
ErrorCode.SESSION_NOT_FOUND: "Session was not found.",
662677
ErrorCode.SESSION_EXPIRED: "Session expired.",
663678
ErrorCode.VISION_BUDGET_EXHAUSTED: "The snapshot budget is exhausted.",
679+
ErrorCode.VIEW_FRAME_RATE_LIMITED: "View frame capture is rate limited.",
664680
ErrorCode.INVALID_URL: "The navigation URL is invalid.",
665681
ErrorCode.DESTINATION_BLOCKED: "The navigation destination is blocked.",
666682
ErrorCode.INVALID_INTERACTION: "The request or interaction is invalid.",

‎src/agent_browser/models.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ class ErrorCode(StrEnum):
7474
SESSION_NOT_FOUND = "SESSION_NOT_FOUND"
7575
SESSION_EXPIRED = "SESSION_EXPIRED"
7676
VISION_BUDGET_EXHAUSTED = "VISION_BUDGET_EXHAUSTED"
77+
VIEW_FRAME_RATE_LIMITED = "VIEW_FRAME_RATE_LIMITED"
7778
INVALID_URL = "INVALID_URL"
7879
DESTINATION_BLOCKED = "DESTINATION_BLOCKED"
7980
INVALID_INTERACTION = "INVALID_INTERACTION"
@@ -215,6 +216,22 @@ class SnapshotResponse(ClosedModel):
215216
_captured_at_is_utc = field_validator("captured_at")(_validate_utc)
216217

217218

219+
class ViewFrameResponse(ClosedModel):
220+
"""Pixel-only response for a local, authenticated viewer relay."""
221+
222+
api_version: ApiVersion = API_VERSION
223+
status: Literal["view_frame"] = "view_frame"
224+
session_id: UUID
225+
screenshot_base64: Annotated[
226+
str,
227+
StringConstraints(min_length=1, max_length=MAX_SCREENSHOT_BASE64_CHARS),
228+
]
229+
viewport: Viewport
230+
captured_at: datetime
231+
232+
_captured_at_is_utc = field_validator("captured_at")(_validate_utc)
233+
234+
218235
class InteractionTarget(ClosedModel):
219236
selector: BoundedSelector | None = None
220237
x: int | None = Field(default=None, ge=0, le=4095)

‎src/agent_browser/runtime.py‎

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,14 @@ class BrowserSnapshot:
152152
viewport: Viewport
153153

154154

155+
@dataclass(frozen=True, slots=True)
156+
class BrowserViewFrame:
157+
"""Image-only local viewing frame; no page text or accessibility tree."""
158+
159+
screenshot_base64: str
160+
viewport: Viewport
161+
162+
155163
class BrowserAdapter(Protocol):
156164
"""The complete browser capability surface owned by a session."""
157165

@@ -168,6 +176,9 @@ async def navigate(self, url: str) -> BrowserPageState:
168176
async def snapshot(self) -> BrowserSnapshot:
169177
"""Capture bounded structured state and a PNG screenshot."""
170178

179+
async def capture_view_frame(self) -> BrowserViewFrame:
180+
"""Capture an image-only frame without consuming model vision budget."""
181+
171182
async def click(
172183
self,
173184
*,
@@ -460,16 +471,7 @@ async def snapshot(self) -> BrowserSnapshot:
460471
try:
461472
async with asyncio.timeout(self._action_timeout):
462473
state = await self._extract_page_state(page)
463-
screenshot = await page.screenshot(
464-
type="png",
465-
full_page=False,
466-
animations="disabled",
467-
caret="hide",
468-
timeout=int(self._action_timeout * 1000),
469-
)
470-
encoded = base64.b64encode(bytes(screenshot)).decode("ascii")
471-
if len(encoded) > MAX_SCREENSHOT_BASE64_CHARS:
472-
raise BrowserOperationError("The browser snapshot exceeded its size limit.")
474+
encoded = await self._capture_png(page)
473475
return BrowserSnapshot(
474476
page=state,
475477
screenshot_base64=encoded,
@@ -478,6 +480,31 @@ async def snapshot(self) -> BrowserSnapshot:
478480
except BaseException as error:
479481
self._raise_operation_error(error, "Snapshot failed.")
480482

483+
async def capture_view_frame(self) -> BrowserViewFrame:
484+
"""Read pixels only; the RC relay never needs DOM or form values."""
485+
page = self._require_page()
486+
try:
487+
async with asyncio.timeout(self._action_timeout):
488+
return BrowserViewFrame(
489+
screenshot_base64=await self._capture_png(page),
490+
viewport=self._viewport,
491+
)
492+
except BaseException as error:
493+
self._raise_operation_error(error, "View capture failed.")
494+
495+
async def _capture_png(self, page: Any) -> str:
496+
screenshot = await page.screenshot(
497+
type="png",
498+
full_page=False,
499+
animations="disabled",
500+
caret="hide",
501+
timeout=int(self._action_timeout * 1000),
502+
)
503+
encoded = base64.b64encode(bytes(screenshot)).decode("ascii")
504+
if len(encoded) > MAX_SCREENSHOT_BASE64_CHARS:
505+
raise BrowserOperationError("The browser frame exceeded its size limit.")
506+
return encoded
507+
481508
async def click(
482509
self,
483510
*,

0 commit comments

Comments
 (0)