Review the exact action below. Approve only if you asked for it. Approval expires after five minutes and works once.
+
+
+
+
+
Ask your chosen model
+
Nothing is sent until you opt in and press Send. Each request sends only the message below and, if selected, a fresh headset screenshot. Replies cannot operate your Frame.
+
+
+
+
+
diff --git a/ui/frame_agent.py b/ui/frame_agent.py
new file mode 100644
index 0000000..ae5a07c
--- /dev/null
+++ b/ui/frame_agent.py
@@ -0,0 +1,140 @@
+"""Agent actions and one-use human approvals. No model SDK or network calls here."""
+import hashlib
+from pathlib import Path
+import secrets
+import shutil
+import subprocess
+import threading
+import time
+
+
+class Approvals:
+ def __init__(self):
+ self.pending = {}
+ self.lock = threading.Lock()
+
+ def request(self, action):
+ with self.lock:
+ now = time.monotonic()
+ self.pending = {k: v for k, v in self.pending.items() if v['expires'] > now}
+ if len(self.pending) >= 100:
+ raise ValueError('Too many pending approvals; wait five minutes')
+ token = secrets.token_urlsafe(24)
+ self.pending[token] = {'action': action, 'approved': False, 'expires': now + 300}
+ return {'confirmation': token, 'action': action, 'approvalPath': '/assistant#confirm=' + token,
+ 'message': 'Ask the user to review and approve this action in Frame Control, then retry with confirmation. Expires in five minutes.'}
+
+ def entry(self, token):
+ entry = self.pending.get(token)
+ if not entry or entry['expires'] <= time.monotonic():
+ raise ValueError('Approval expired or unknown; request a new one')
+ return entry
+
+ def inspect(self, token):
+ with self.lock:
+ entry = self.entry(token)
+ return {'action': entry['action'], 'approved': entry['approved']}
+
+ def decide(self, token, accept):
+ with self.lock:
+ entry = self.entry(token)
+ if accept is True:
+ entry['approved'] = True
+ else:
+ del self.pending[token]
+ return {'message': 'Approved for one use' if accept is True else 'Rejected'}
+
+ def consume(self, token, action):
+ with self.lock:
+ entry = self.entry(token)
+ if entry['action'] != action or not entry['approved']:
+ raise ValueError('This exact action needs approval in Frame Control')
+ del self.pending[token] # consume before starting, including on failure
+
+
+approvals = Approvals()
+
+
+def validate(name, args):
+ fields = {
+ 'launch': {'appid'}, 'install': {'id'}, 'uninstall': {'id'},
+ 'send_text': {'text'}, 'send_file': {'path'}, 'panel': {'id'},
+ 'power': {'action'}, 'keep_awake': {'action'},
+ }
+ if name not in fields or not isinstance(args, dict) or set(args) != fields[name]:
+ raise ValueError('Unknown action or arguments')
+ if any(not isinstance(v, str) or not v or len(v) > 65536 for v in args.values()):
+ raise ValueError('Arguments must be nonempty strings (maximum 65536 characters)')
+ if name == 'power' and args['action'] not in ('suspend', 'reboot', 'poweroff'):
+ raise ValueError('Unknown power action')
+ if name == 'keep_awake' and args['action'] not in ('on', 'off', 'status'):
+ raise ValueError('Expected on, off or status')
+ action = {'name': name, 'arguments': dict(args)}
+ if name == 'send_file':
+ path = Path(args['path']).expanduser().resolve(strict=True)
+ if not path.is_file() or path.stat().st_size > 16 * 1024**2:
+ raise ValueError('Choose a regular file of at most 16 MiB')
+ # Bind approval to bytes, not just a mutable filename.
+ with path.open('rb') as stream:
+ data = stream.read(16 * 1024**2 + 1)
+ if len(data) > 16 * 1024**2:
+ raise ValueError('File grew beyond 16 MiB')
+ action['arguments']['path'] = str(path)
+ action['sha256'] = hashlib.sha256(data).hexdigest()
+ action['bytes'] = len(data)
+ return action
+
+
+def call(server, body):
+ name, args = body.get('name'), body.get('arguments', {})
+ action = validate(name, args)
+ if name in ('install', 'uninstall', 'panel') and not server.FLATPAK_ID.fullmatch(args['id']):
+ raise ValueError('Expected a Flatpak application ID')
+ if name == 'launch' and not server.APPID.fullmatch(args['appid']):
+ raise ValueError('Expected a Steam app ID')
+ if name == 'keep_awake' and args['action'] == 'status':
+ return keep_awake(server, 'status')
+ token = body.get('confirmation')
+ if not token:
+ return approvals.request(action)
+ approvals.consume(token, action)
+ if name == 'launch':
+ return server.launch(args)
+ if name in ('install', 'uninstall'):
+ return server.flatpak({**args, 'action': name})
+ if name == 'send_text':
+ return server.clipboard(args)
+ if name == 'send_file':
+ # Stage the reviewed bytes before the existing transfer helper reads them.
+ import tempfile
+ with tempfile.TemporaryDirectory(prefix='frame-agent-') as tmp:
+ source = Path(action['arguments']['path'])
+ with source.open('rb') as stream:
+ data = stream.read(16 * 1024**2 + 1)
+ if hashlib.sha256(data).hexdigest() != action['sha256']:
+ raise ValueError('File changed after approval')
+ staged = Path(tmp) / source.name
+ staged.write_bytes(data)
+ return {'message': server.push_file(staged)}
+ if name == 'power':
+ if server.LOCAL:
+ raise ValueError('Use the Frame Control power controls to enter the password; MCP never takes passwords')
+ return server.open_thing({'what': args['action']})
+ if name == 'keep_awake':
+ return keep_awake(server, args['action'])
+ return run_script(server, 'panel-on-frame.sh', [args['id']])
+
+
+def run_script(server, name, args):
+ script = server.HERE.parent / 'scripts' / name
+ if not script.exists() or not shutil.which('zsh') or server.LOCAL:
+ raise ValueError(name + ' requires a computer with zsh and the matching script installed')
+ result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60)
+ if result.returncode:
+ raise ValueError(result.stderr.strip() or 'Script failed')
+ return {'message': result.stdout.strip()}
+
+
+def keep_awake(server, action):
+ # PR #16 owns this interface. Never silently change timers or claim a lease.
+ return run_script(server, 'keep-awake.sh', [action])
diff --git a/ui/frame_assistant.py b/ui/frame_assistant.py
new file mode 100644
index 0000000..c76c1e0
--- /dev/null
+++ b/ui/frame_assistant.py
@@ -0,0 +1,53 @@
+"""Explicit, per-request forwarding to a user-chosen chat-completions endpoint."""
+import base64
+import json
+from urllib.parse import urlsplit
+from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
+
+
+class NoRedirect(HTTPRedirectHandler):
+ def redirect_request(self, *args, **kwargs):
+ raise ValueError('Endpoint redirected; enter its final URL explicitly')
+
+
+def chat(body, screenshot):
+ if body.get('consent') is not True:
+ raise ValueError('Opt in before sending a message')
+ endpoint, model, prompt = (body.get(k) for k in ('endpoint', 'model', 'prompt'))
+ if any(not isinstance(v, str) or not v.strip() for v in (endpoint, model, prompt)):
+ raise ValueError('Endpoint, model and message are required')
+ if len(prompt) > 32000 or len(model) > 200 or len(endpoint) > 2048:
+ raise ValueError('Message, model or endpoint is too long')
+ url = urlsplit(endpoint)
+ if not url.hostname or url.username or url.password or url.fragment or url.query:
+ raise ValueError('Use an endpoint URL without credentials, query or fragment')
+ if url.scheme != 'https' and not (url.scheme == 'http' and url.hostname in ('localhost', '127.0.0.1', '::1')):
+ raise ValueError('Use HTTPS, or HTTP on loopback for a local model')
+ key = body.get('key', '')
+ if not isinstance(key, str) or len(key) > 4096 or '\n' in key or '\r' in key:
+ raise ValueError('Invalid API key')
+ content = prompt
+ if body.get('screenshot') is True:
+ png = screenshot()
+ if len(png) > 12 * 1024**2:
+ raise ValueError('Screenshot is too large')
+ content = [{'type': 'text', 'text': prompt}, {'type': 'image_url', 'image_url': {
+ 'url': 'data:image/png;base64,' + base64.b64encode(png).decode()}}]
+ payload = {'model': model, 'messages': [{'role': 'user', 'content': content}], 'stream': False}
+ headers = {'Content-Type': 'application/json'}
+ if key:
+ headers['Authorization'] = 'Bearer ' + key
+ request = Request(endpoint, data=json.dumps(payload).encode(), headers=headers)
+ # No environment proxy or redirects: credentials/context go only to the chosen URL.
+ try:
+ with build_opener(ProxyHandler({}), NoRedirect()).open(request, timeout=60) as response:
+ raw = response.read(2 * 1024**2 + 1)
+ if len(raw) > 2 * 1024**2:
+ raise ValueError('Endpoint response is too large')
+ answer = json.loads(raw)['choices'][0]['message']['content']
+ if not isinstance(answer, str):
+ raise ValueError('Expected a text reply')
+ except Exception:
+ # Provider error bodies and URLs can contain credentials or echoed prompts.
+ raise ValueError('Endpoint request failed or returned an unsupported reply; check URL, model and credentials') from None
+ return {'reply': answer}
diff --git a/ui/frame_mcp.py b/ui/frame_mcp.py
new file mode 100644
index 0000000..6719dcf
--- /dev/null
+++ b/ui/frame_mcp.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+"""Key-free stdio MCP adapter for an already running Frame Control HTTP server."""
+import argparse
+import base64
+import json
+import os
+import sys
+from urllib.parse import urlencode, urlsplit
+from urllib.error import HTTPError
+from urllib.request import ProxyHandler, Request, build_opener, HTTPRedirectHandler
+
+MAX_LINE = 1024 * 1024
+
+
+class NoRedirect(HTTPRedirectHandler):
+ def redirect_request(self, *args, **kwargs):
+ raise ValueError('Frame Control must not redirect')
+
+
+class Client:
+ def __init__(self, url, key='1'):
+ parsed = urlsplit(url)
+ if parsed.scheme != 'http' or parsed.hostname not in ('localhost', '127.0.0.1') or parsed.path not in ('', '/') or parsed.query or parsed.fragment or parsed.username or parsed.password:
+ raise ValueError('Frame Control URL must be HTTP loopback with no path or credentials')
+ self.url, self.key = url.rstrip('/'), key
+ self.opener = build_opener(ProxyHandler({}), NoRedirect())
+
+ def request(self, path, body=None, image=False):
+ req = Request(self.url + path, data=None if body is None else json.dumps(body).encode(),
+ headers={'X-Frame-UI': self.key, 'Content-Type': 'application/json'})
+ try:
+ with self.opener.open(req, timeout=360) as res:
+ data = res.read(16 * 1024**2 + 1)
+ except HTTPError as exc:
+ with exc:
+ raw = exc.read(65536)
+ try:
+ message = json.loads(raw).get('error', 'HTTP ' + str(exc.code))
+ except (ValueError, AttributeError):
+ message = 'HTTP ' + str(exc.code)
+ raise ValueError(str(message)) from None
+ if len(data) > 16 * 1024**2:
+ raise ValueError('Frame Control response too large')
+ return data if image else json.loads(data)
+
+
+def tool(name, description, properties=None, required=None, read=False):
+ return {'name': name, 'description': description, 'inputSchema': {
+ 'type': 'object', 'properties': properties or {}, 'required': required or [], 'additionalProperties': False},
+ 'annotations': {'readOnlyHint': read, 'destructiveHint': not read, 'openWorldHint': True}}
+
+
+def string(description):
+ return {'type': 'string', 'description': description}
+
+
+TOOLS = [tool('status', 'Read battery, services and installed apps.', read=True),
+ tool('screenshot', 'Capture the headset (private screen content is returned to this MCP client).',
+ {'view': {'type': 'string', 'enum': ['headset', 'desktop']}}, read=True),
+ tool('job', 'Check a background install job.', {'id': string('Job ID')}, ['id'], read=True)]
+for name, field, description in [
+ ('launch', 'appid', 'Launch an installed Steam app by ID.'),
+ ('install', 'id', 'Install a free Flatpak from Flathub to the user account.'),
+ ('uninstall', 'id', 'Uninstall a user Flatpak.'),
+ ('send_text', 'text', 'Send text to the Frame desktop clipboard.'),
+ ('send_file', 'path', 'Send a file (up to 16 MiB) from the HTTP server computer to Frame Downloads.'),
+ ('panel', 'id', 'Open an installed Flatpak as a floating panel; needs zsh on the computer.'),
+ ('power', 'action', 'suspend, reboot or poweroff. Opens a terminal for the user password.'),
+ ('keep_awake', 'action', 'on, off or status using the optional PR #16 script. on changes idle timers; off restores them. Never automatic.'),
+]:
+ TOOLS.append(tool(name, description + ' Mutations require user approval at the returned approvalUrl; retry with its confirmation token. Never approve on the user’s behalf.',
+ {field: string(description), 'confirmation': string('Token returned by a previous call, after the user approves')}, [field]))
+
+
+def call(client, name, args):
+ spec = next((t for t in TOOLS if t['name'] == name), None)
+ if not spec or not isinstance(args, dict):
+ raise ValueError('Unknown tool or invalid arguments')
+ schema = spec['inputSchema']
+ if set(args) - set(schema['properties']) or set(schema['required']) - set(args):
+ raise ValueError('Unknown or missing arguments')
+ if any(not isinstance(v, str) for v in args.values()):
+ raise ValueError('Arguments must be strings')
+ if name == 'screenshot':
+ view = args.get('view', 'headset')
+ if view not in ('headset', 'desktop'):
+ raise ValueError('Unknown screenshot view')
+ png = client.request('/api/screenshot?' + urlencode({'view': view}), image=True)
+ return {'content': [{'type': 'image', 'mimeType': 'image/png', 'data': base64.b64encode(png).decode()}]}
+ if name in ('status', 'job'):
+ result = client.request('/api/' + name + ('?' + urlencode(args) if args else ''))
+ else:
+ args = dict(args)
+ confirmation = args.pop('confirmation', None)
+ result = client.request('/api/agent/call', {'name': name, 'arguments': args, 'confirmation': confirmation})
+ if 'approvalPath' in result:
+ result['approvalUrl'] = client.url + result['approvalPath']
+ return {'content': [{'type': 'text', 'text': json.dumps(result)}]}
+
+
+def dispatch(client, message):
+ if not isinstance(message, dict) or message.get('jsonrpc') != '2.0' or not isinstance(message.get('method'), str):
+ return {'jsonrpc': '2.0', 'id': None, 'error': {'code': -32600, 'message': 'Invalid request'}}
+ if 'id' not in message:
+ return None
+ method, params = message['method'], message.get('params', {})
+ response = {'jsonrpc': '2.0', 'id': message['id']}
+ if not isinstance(params, dict):
+ return {**response, 'error': {'code': -32602, 'message': 'Invalid params'}}
+ if method == 'initialize':
+ requested = params.get('protocolVersion')
+ result = {'protocolVersion': requested if requested in ('2024-11-05', '2025-03-26', '2025-06-18') else '2025-06-18',
+ 'capabilities': {'tools': {}}, 'serverInfo': {'name': 'frame-control', 'version': '1.0.0'}}
+ elif method == 'ping':
+ result = {}
+ elif method == 'tools/list':
+ result = {'tools': TOOLS}
+ elif method == 'tools/call':
+ try:
+ result = call(client, params.get('name'), params.get('arguments', {}))
+ except Exception as exc:
+ result = {'isError': True, 'content': [{'type': 'text', 'text': 'Frame Control: ' + str(exc)}]}
+ else:
+ return {**response, 'error': {'code': -32601, 'message': 'Method not found'}}
+ return {**response, 'result': result}
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--url', default='http://127.0.0.1:47810')
+ args = parser.parse_args()
+ client = Client(args.url, os.environ.get('FRAME_UI_KEY', '1'))
+ while True:
+ line = sys.stdin.buffer.readline(MAX_LINE + 1)
+ if not line:
+ break
+ if len(line) > MAX_LINE:
+ print('MCP request too large', file=sys.stderr)
+ return 1
+ try:
+ response = dispatch(client, json.loads(line))
+ except (ValueError, UnicodeError):
+ response = {'jsonrpc': '2.0', 'id': None, 'error': {'code': -32700, 'message': 'Parse error'}}
+ if response is not None:
+ print(json.dumps(response), flush=True)
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/ui/index.html b/ui/index.html
index 8ac1265..3b106a8 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -591,6 +591,7 @@
+
Assistant and AI agents
Use your own model endpoint, or review a proposed MCP action. Nothing is sent to a model until you opt in.
diff --git a/ui/server.py b/ui/server.py
index dd86ff6..d5ca4d1 100755
--- a/ui/server.py
+++ b/ui/server.py
@@ -36,6 +36,8 @@ from urllib.parse import parse_qs, unquote, urlparse
# sys.path, so add it for the sibling modules below.
sys.path.insert(0, str(Path(__file__).resolve().parent))
+import frame_agent # noqa: E402
+import frame_assistant # noqa: E402
import frame_android # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
@@ -1227,7 +1229,20 @@ def _sweep_one(prefix, d):
pass
-POST = {"/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
+def agent_call(body):
+ return frame_agent.call(sys.modules[__name__], body)
+
+
+def assistant_chat(body):
+ return frame_assistant.chat(body, headset_view)
+
+
+def agent_approval(body):
+ return frame_agent.approvals.decide(body.get("confirmation"), body.get("accept"))
+
+
+POST = {"/api/agent/call": agent_call, "/api/agent/approval": agent_approval,
+ "/api/assistant/chat": assistant_chat, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel}
@@ -1331,6 +1346,12 @@ class Handler(BaseHTTPRequestHandler):
try:
if path in ("/", "/index.html"):
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
+ elif path == "/assistant":
+ page = (HERE / "assistant.html").read_text().replace("__FRAME_KEY__", json.dumps(UI_KEY).replace("<", "\\u003c"))
+ self.send_bytes(page.encode(), "text/html; charset=utf-8")
+ elif path == "/api/agent/approval":
+ token = (parse_qs(url.query).get("confirmation") or [""])[0]
+ self.send_json(frame_agent.approvals.inspect(token))
elif path == "/api/host":
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
@@ -1377,6 +1398,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"error": "not found"}, 404)
except Failure as e:
self.send_error_json(str(e), e.status, e.apk)
+ except ValueError as e:
+ self.send_json({"error": str(e)}, 400)
except frame_android.FrameError as e:
self.send_error_json(str(e), 502)
except Exception as e:
From 6a8e3fadbfea500f3d51a4bfcf6cd75366aaf149 Mon Sep 17 00:00:00 2001
From: saphid <4596216+saphid@users.noreply.github.com>
Date: Mon, 28 Sep 2026 22:21:22 +1000
Subject: [PATCH 2/5] Open assistant on Frame and document verified agent
workflows
---
README.md | 1 +
docs/agents.md | 153 ++++++++++++++++++++++++++++++++++
docs/frame-control.md | 10 +++
docs/testing.md | 8 ++
scripts/assistant-on-frame.py | 92 ++++++++++++++++++++
tests/e2e/test_agents.py | 46 ++++++++++
6 files changed, 310 insertions(+)
create mode 100644 docs/agents.md
create mode 100644 scripts/assistant-on-frame.py
create mode 100644 tests/e2e/test_agents.py
diff --git a/README.md b/README.md
index 6dd32be..d900cb7 100644
--- a/README.md
+++ b/README.md
@@ -204,6 +204,7 @@ Frame's software fits together, all checked against a real headset and labelled
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
+| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
| [Open questions](docs/open-questions.md) | What's still unchecked |
diff --git a/docs/agents.md b/docs/agents.md
new file mode 100644
index 0000000..f01d315
--- /dev/null
+++ b/docs/agents.md
@@ -0,0 +1,153 @@
+# Frame Control for AI agents
+
+**Documented interface:** Frame Control's own stdlib Python MCP adapter wraps
+its loopback HTTP API. No API key, hosted service, model SDK or third-party
+helper app is needed. The assistant is our HTML/Python implementation hosted
+in the platform Chromium browser. Its optional LLM endpoint is user configuration.
+Installing other apps is an optional management action, never a prerequisite.
+
+## Connect an MCP client
+
+Start the HTTP server from this checkout:
+
+```sh
+python3 ui/server.py --port 47810
+```
+
+Add a stdio server to your MCP client (use absolute paths):
+
+```json
+{
+ "mcpServers": {
+ "frame-control": {
+ "command": "python3",
+ "args": ["/absolute/path/frame-control/ui/frame_mcp.py", "--url", "http://127.0.0.1:47810"]
+ }
+ }
+}
+```
+
+The desktop app uses a random port; use that port with `--url`, or run the
+checkout server above. If the HTTP server uses `FRAME_UI_KEY`, pass the same
+value in the MCP process environment. This is local access control, not an LLM
+API key. The adapter only accepts loopback HTTP servers, refuses redirects and
+ignores environment proxies. Stdout contains newline-delimited JSON-RPC only.
+It supports MCP initialization, ping, tool listing and tool calls; no sampling,
+resources, prompts or streaming transport.
+
+| Tool | Arguments | Effect |
+|---|---|---|
+| `status` | none | Battery, services, installed games and Flatpaks |
+| `screenshot` | `view`: `headset` (default) or `desktop` | Returns PNG image content to the MCP client |
+| `job` | `id` | Background install status; poll until `done`, inspect `error` |
+| `launch` | `appid` | Launch an installed Steam app |
+| `install` / `uninstall` | `id` | Install from Flathub / remove a user Flatpak |
+| `send_text` | `text` | Frame desktop clipboard; desktop must be open |
+| `send_file` | `path` | File on the HTTP server computer, up to 16 MiB, copied to Frame `~/Downloads` |
+| `panel` | `id` | Launch an installed Flatpak as a panel using the existing launcher |
+| `power` | `action`: `suspend`, `reboot`, `poweroff` | Open a terminal for the user to enter the sudo password |
+| `keep_awake` | `action`: `on`, `off`, `status` | Optional keep-awake script interface |
+
+Only install free software with its developer's consent. There is no purchase,
+entitlement bypass or arbitrary shell tool. `install` returns a background job
+ID; it does not claim the installation has finished. APK and sideloaded title
+installs remain in the main UI for now.
+
+### Approval is a separate human action
+
+Every mutation first returns an `approvalUrl`, exact action and `confirmation`
+token. Ask the user to open that URL and choose **Approve this action** or
+**Reject**. Then repeat the same tool and arguments with the token in
+`confirmation`. The server refuses execution before approval, changed arguments,
+expired tokens and reuse. A file approval binds the content hash as well as the
+path. Approvals last five minutes and disappear when the HTTP server restarts.
+A failed execution also consumes the approval; review a fresh request to retry.
+The panel does not execute an action merely because it was approved.
+
+MCP has no approval tool. This is protection against accidental model tool
+calls, not a sandbox against a client with independent shell/HTTP access to your
+computer. Grant the MCP client only the access you intend. Status and captures
+are returned directly to that client, which may forward them to its configured
+model. The assistant's separate opt-in does not govern an external MCP client.
+
+Power still requires the existing password prompt in a local terminal. MCP
+never receives passwords. Power via `FRAME_LOCAL=1` is unsupported: use the main
+UI. The panel launcher and keep-awake adapter require zsh on the computer.
+
+[PR #16](https://github.com/saphid/frame-control/pull/16) owns
+`scripts/keep-awake.sh on|off|status`. This branch does not copy or change it.
+Until that script is present, the tool reports it unavailable. Keep-awake is
+never automatic: `on` changes the shared idle timers; explicitly approve `off`
+to restore them after work. It is not a per-agent lease; coordinate with other
+users. No changes are made to the analytics/update interfaces in
+[PR #17](https://github.com/saphid/frame-control/pull/17). Prompts, keys, model
+replies, screenshots and approval payloads are not sent to analytics.
+
+## Assistant panel
+
+Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
+To put the same page in the headset, with the HTTP server still running:
+
+```sh
+python3 scripts/assistant-on-frame.py --port 47810
+```
+
+This starts an SSH reverse forward bound to Frame loopback (port 47812 by
+default), then a dedicated Chromium profile tagged as a SteamVR panel. Keep the
+command running. Ctrl-C closes this browser profile and the tunnel; it leaves
+other Chromium windows and the existing HTTP server alone. A failed cleanup
+prints the temporary profile path so it can be removed when the Frame returns.
+Use `--frame-port` if the default is busy. Chromium must already be available as
+`org.chromium.Chromium`; the launcher never installs anything automatically.
+Place the panel with SteamVR's normal docking controls.
+
+Enter your full **chat-completions endpoint**, model name and optional key.
+An OpenAI-compatible local server works without a key; no OpenAI account is
+required. HTTP is allowed only on loopback; other endpoints require HTTPS.
+Loopback refers to the computer running the HTTP server, even in the headset.
+Endpoints with embedded credentials, query strings or redirects are refused.
+
+Check the message consent box and press **Send message**. Screenshot context is
+a separate unchecked box and sends one fresh capture with that request. Both
+boxes reset after sending, and changing endpoint/model revokes consent. Nothing
+is sent when opening the page or entering configuration. There is no model
+list fetch, saved history, automatic screenshot capture or assistant telemetry.
+Each send is independent: previous messages and replies are not included.
+
+Configuration, credentials and chat remain in page memory; close/reload the page
+or choose **Clear everything** to clear them. A request already sent cannot be
+recalled. Only the chosen endpoint gets the request; proxy environment variables
+and redirects are disabled. Its privacy and retention policy still applies.
+Replies are plain text and cannot call tools or operate the Frame. A model must
+support image inputs to accept screenshot context.
+
+## Evidence and limits
+
+**Verified 2026-09-28, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** loopback HTTP
+status through an SSH reverse tunnel; platform Chromium created a separate
+SteamVR panel (confirmed in `GAMESCOPE_FOCUSABLE_APPS`); headset capture returned
+a PNG. These checks preceded the UI implementation. No power or global settings
+were changed.
+
+**Inferred:** visual comfort and controller keyboard usability while wearing
+the headset; panel creation in gamescope alone does not establish these.
+Windows/Linux launcher support, live third-party model endpoints, installs,
+uninstalls, power and keep-awake changes are not covered by that feasibility
+check. See the PR for the final unit and end-to-end results.
+
+**Verified end to end on the same Frame/build (2026-09-28):** a stdio MCP client
+initialized, read status, retrieved a headset PNG, and transferred a test file
+only after approval through the Chromium page. Remote file bytes matched;
+reusing the confirmation was rejected. The actual headset Chromium page sent
+text and then separately opted-in image context to a local test endpoint and
+displayed its replies. Without consent there were zero endpoint requests.
+The test endpoint returned canned replies: model inference and a live external
+provider remain **unverified**. The launcher’s Ctrl-C cleanup was checked;
+profiles, SSH tunnels and the test file were removed. No installs, removals,
+launches of user games, power operations or keep-awake changes were performed.
+
+**Verified locally:** unit coverage includes the stdio subprocess, approval
+binding/expiry/replay/concurrency, file-change rejection, and a real local HTTP
+endpoint for opt-in, text/image payloads and redirect refusal. Fake-Frame
+regressions are in `tests/e2e/test_agents.py`; local Docker execution was blocked
+because the Docker daemon was unavailable. CI runs those regressions.
diff --git a/docs/frame-control.md b/docs/frame-control.md
index 299c57c..6e4c872 100644
--- a/docs/frame-control.md
+++ b/docs/frame-control.md
@@ -150,3 +150,13 @@ npm run dist:linux # Linux: AppImage and .deb, x64 and arm64
Pushing a `v*` tag builds all three in GitHub Actions and attaches them to the
release (`.github/workflows/release.yml`).
+
+## AI agents and assistant
+
+**Documented:** [the MCP adapter and assistant panel](agents.md) are Frame
+Control implementations. MCP wraps this HTTP API without API keys. Changes
+require a separate user approval; power also retains its password prompt. The
+assistant uses a user-chosen endpoint and sends nothing until the user opts in
+for a message. Screenshot context is separately opt-in. Model replies cannot
+operate the headset. Tools → Open assistant opens the page; the linked guide
+covers putting it in a Chromium panel on the Frame.
diff --git a/docs/testing.md b/docs/testing.md
index b06577d..469440e 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -148,3 +148,11 @@ For example, on 2026-09-27 the smoke test found that Steam's `create-shortcut`
refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
accepted. The fake now refuses them the same way, and Frame Control makes ids
Steam accepts.
+
+## Agent interfaces
+
+`tests/test_agent.py` exercises MCP stdio, exact-action human approvals and the
+assistant against an in-process HTTP endpoint with canned responses (no keys or
+external calls). `tests/e2e/test_agents.py` runs the MCP/HTTP/SSH path against the
+fake Frame for approved installs, clipboard and file transfer. Headset Chromium
+rendering and real screenshots still need a device; see [agent evidence](agents.md#evidence-and-limits).
diff --git a/scripts/assistant-on-frame.py b/scripts/assistant-on-frame.py
new file mode 100644
index 0000000..7c8b59f
--- /dev/null
+++ b/scripts/assistant-on-frame.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+"""Open Frame Control's assistant as a Chromium panel. Ctrl-C closes it and its SSH tunnel.
+
+Start ui/server.py first. Requires the platform Chromium Flatpak and zsh on the
+computer (the existing panel launcher). No model endpoint or key is configured.
+"""
+import argparse
+import os
+from pathlib import Path
+import re
+import shlex
+import shutil
+import subprocess
+import sys
+import time
+import uuid
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--port', type=int, default=47810, help='local Frame Control port')
+ parser.add_argument('--frame-port', type=int, default=47812, help='Frame loopback tunnel port')
+ args = parser.parse_args()
+ alias = os.environ.get('FRAME_ALIAS', 'frame')
+ if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*', alias) or any(not 1 <= p <= 65535 for p in (args.port, args.frame_port)):
+ parser.error('Invalid alias or port')
+ if not shutil.which('zsh'):
+ parser.error('The panel launcher requires zsh on this computer')
+ sys.path.insert(0, str(ROOT / 'ui'))
+ from frame_mcp import Client
+ Client('http://127.0.0.1:' + str(args.port), os.environ.get('FRAME_UI_KEY', '1')).request('/api/host')
+ profile = '/tmp/frame-control-assistant-' + uuid.uuid4().hex
+ tunnel = subprocess.Popen(['ssh', '-N', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8',
+ '-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=15',
+ '-o', 'ServerAliveCountMax=2', '-R',
+ f'127.0.0.1:{args.frame_port}:127.0.0.1:{args.port}', alias])
+ try:
+ # Check the forwarded page before starting a browser; no arbitrary sleeps.
+ probe = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', alias,
+ 'curl --retry 5 --retry-connrefused --retry-delay 1 --max-time 10 -fsS ' +
+ shlex.quote(f'http://127.0.0.1:{args.frame_port}/assistant')],
+ stdout=subprocess.DEVNULL, timeout=30)
+ if probe.returncode or tunnel.poll() is not None:
+ raise RuntimeError('Could not forward Frame Control to the Frame')
+ subprocess.run(['zsh', str(ROOT / 'scripts/panel-on-frame.sh'), '--name', 'Frame Control Assistant',
+ 'org.chromium.Chromium', '--user-data-dir=' + profile, '--no-first-run',
+ '--disable-background-networking', '--disable-sync',
+ f'--app=http://127.0.0.1:{args.frame_port}/assistant'], check=True, timeout=45)
+ print('Assistant panel open. Ctrl-C closes this panel and its tunnel.', flush=True)
+ tunnel.wait()
+ raise RuntimeError('SSH tunnel ended')
+ except KeyboardInterrupt:
+ return 0
+ finally:
+ tunnel.terminate()
+ try:
+ tunnel.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ tunnel.kill()
+ tunnel.wait()
+ # Only this unique browser profile, never a shared Chromium instance.
+ cleanup = '''import os, pathlib, signal, shutil, sys, time
+profile = sys.argv[1]
+needle = ('--user-data-dir=' + profile).encode()
+owned = []
+for p in pathlib.Path('/proc').iterdir():
+ try:
+ if p.name.isdigit() and p.stat().st_uid == os.getuid() and needle in (p / 'cmdline').read_bytes().split(b'\\0'):
+ owned.append(int(p.name))
+ except OSError:
+ pass
+for sig in (signal.SIGTERM, signal.SIGKILL):
+ for pid in owned:
+ try: os.kill(pid, sig)
+ except ProcessLookupError: pass
+ time.sleep(.3)
+shutil.rmtree(profile, ignore_errors=True)
+'''
+ result = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', alias,
+ 'python3 - ' + shlex.quote(profile)], input=cleanup, text=True, timeout=20)
+ if result.returncode:
+ print('Cleanup failed; close the assistant panel and remove ' + profile + ' on the Frame.', file=sys.stderr)
+
+
+if __name__ == '__main__':
+ try:
+ sys.exit(main())
+ except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
+ print(str(exc), file=sys.stderr)
+ sys.exit(1)
diff --git a/tests/e2e/test_agents.py b/tests/e2e/test_agents.py
new file mode 100644
index 0000000..f02f338
--- /dev/null
+++ b/tests/e2e/test_agents.py
@@ -0,0 +1,46 @@
+"""Real HTTP/MCP adapter against fake-Frame SSH; no model service needed."""
+import json
+from pathlib import Path
+import sys
+
+import harness
+from harness import api, ok, finished, ssh
+
+sys.path.insert(0, str(harness.ROOT / 'ui'))
+import frame_mcp
+
+
+class Agents(harness.FrameTestCase):
+ def client(self):
+ return frame_mcp.Client('http://127.0.0.1:%d' % harness.Server.port)
+
+ def call(self, name, args):
+ return json.loads(frame_mcp.call(self.client(), name, args)['content'][0]['text'])
+
+ def approve(self, proposal):
+ ok('POST', '/api/agent/approval', {'confirmation': proposal['confirmation'], 'accept': True})
+ return proposal['confirmation']
+
+ def test_status_and_approved_install_job(self):
+ self.assertIn('battery', self.call('status', {}))
+ proposal = self.call('install', {'id': 'org.example.AgentTest'})
+ before = api('POST', '/api/agent/call', {'name': 'install', 'arguments': {'id': 'org.example.AgentTest'}, 'confirmation': proposal['confirmation']})
+ self.assertEqual(before[0], 400)
+ token = self.approve(proposal)
+ job = self.call('install', {'id': 'org.example.AgentTest', 'confirmation': token})
+ self.assertFalse(finished(job).get('error'))
+ self.assertIn('org.example.AgentTest', ssh('flatpak list --app --columns=application'))
+ denied = api('POST', '/api/agent/call', {'name': 'install', 'arguments': {'id': 'org.example.AgentTest'}, 'confirmation': token})
+ self.assertEqual(denied[0], 400)
+
+ def test_approved_file_and_text(self):
+ path = Path(self.path('agent-note.txt'))
+ path.write_text('MCP file content\n')
+ args = {'path': str(path)}
+ token = self.approve(self.call('send_file', args))
+ self.call('send_file', {**args, 'confirmation': token})
+ self.assertEqual(ssh('cat ~/Downloads/agent-note.txt'), path.read_text())
+ args = {'text': 'MCP clipboard text'}
+ token = self.approve(self.call('send_text', args))
+ self.call('send_text', {**args, 'confirmation': token})
+ self.assertEqual(harness.state()['clipboard'], ['MCP clipboard text'])
From b5cf8253e698b532936283820846731c4853b5b3 Mon Sep 17 00:00:00 2001
From: saphid <4596216+saphid@users.noreply.github.com>
Date: Mon, 28 Sep 2026 22:29:18 +1000
Subject: [PATCH 3/5] Bind approval UI to current request and verify panel
cleanup
---
docs/agents.md | 8 ++++++-
docs/img/assistant-panel.png | Bin 0 -> 144915 bytes
scripts/assistant-on-frame.py | 16 +++++++++----
tests/assistant_ui.cjs | 43 ++++++++++++++++++++++++++++++++++
tests/test_agent.py | 10 ++++++++
ui/assistant.html | 18 ++++++++------
6 files changed, 83 insertions(+), 12 deletions(-)
create mode 100644 docs/img/assistant-panel.png
create mode 100644 tests/assistant_ui.cjs
diff --git a/docs/agents.md b/docs/agents.md
index f01d315..1d5e6c0 100644
--- a/docs/agents.md
+++ b/docs/agents.md
@@ -150,4 +150,10 @@ launches of user games, power operations or keep-awake changes were performed.
binding/expiry/replay/concurrency, file-change rejection, and a real local HTTP
endpoint for opt-in, text/image payloads and redirect refusal. Fake-Frame
regressions are in `tests/e2e/test_agents.py`; local Docker execution was blocked
-because the Docker daemon was unavailable. CI runs those regressions.
+because the Docker daemon was unavailable. The ARM64 fake-Frame CI job passed
+on this branch (run 36421345682).
+
+**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
+browser profile and SSH tunnel and remove the profile and panel log.
+
+
diff --git a/docs/img/assistant-panel.png b/docs/img/assistant-panel.png
new file mode 100644
index 0000000000000000000000000000000000000000..514a1d51c7d8fad4cb37c2a0010a6f60992ef7e0
GIT binary patch
literal 144915
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