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.