diff --git a/AGENTS.md b/AGENTS.md
index 516e5923ef5c1a0c52c80dca5b3bde97e8392d55..9203f930028e00e54849f1963328950ee2901452 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,8 +8,9 @@
and can fabricate some inputs and outputs. To test things, launch shelley with the relevant flag
to only expose this model, and use shelley with a browser.
6. Build the UI (`make ui` or `cd ui && pnpm install && pnpm run build`) before running Go tests so `ui/dist` exists for the embed.
-7. Run Go unit tests with `go test ./server` (or narrower packages while iterating) once the UI bundle is built.
-8. To programmatically type into the React message input (e.g., in browser automation), you must use React's internal setter:
+7. Run TypeScript type checking with `cd ui && pnpm run type-check`. Run linting with `pnpm run lint`.
+8. Run Go unit tests with `go test ./server` (or narrower packages while iterating) once the UI bundle is built.
+9. To programmatically type into the React message input (e.g., in browser automation), you must use React's internal setter:
```javascript
const input = document.querySelector('[data-testid="message-input"]');
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value").set;
@@ -17,10 +18,10 @@
input.dispatchEvent(new Event('input', { bubbles: true }));
```
Simply setting `input.value = '...'` won't work because React won't detect the change.
-9. Commit your changes before finishing your turn.
-10. If you are testing Shelley itself, be aware that you might be running "under" shelley,
+10. Commit your changes before finishing your turn.
+11. If you are testing Shelley itself, be aware that you might be running "under" shelley,
and indiscriminately running pkill -f shelley may break things.
-11. To test the Shelley UI in a separate instance, build with `make build`, then run on a
+12. To test the Shelley UI in a separate instance, build with `make build`, then run on a
different port with a separate database:
```
./bin/shelley -config /exe.dev/shelley.json -db /tmp/shelley-test.db serve -port 8002
diff --git a/claudetool/output_iframe.go b/claudetool/output_iframe.go
new file mode 100644
index 0000000000000000000000000000000000000000..ff7d3c8f09a59d8a0dcb3576f71674b8e4929259
--- /dev/null
+++ b/claudetool/output_iframe.go
@@ -0,0 +1,87 @@
+package claudetool
+
+import (
+ "context"
+ "encoding/json"
+
+ "shelley.exe.dev/llm"
+)
+
+// OutputIframeTool displays sandboxed HTML content to the user.
+var OutputIframeTool = &llm.Tool{
+ Name: outputIframeName,
+ Description: outputIframeDescription,
+ InputSchema: llm.MustSchema(outputIframeInputSchema),
+ Run: outputIframeRun,
+}
+
+const (
+ outputIframeName = "output_iframe"
+ outputIframeDescription = `Display HTML content to the user in a sandboxed iframe.
+
+Use this tool for visualizations like charts, graphs, and HTML demos that the user should see.
+The HTML will be rendered in a secure sandbox with scripts enabled but isolated from the parent page.
+
+Do NOT use this tool for:
+- Regular text responses (use normal messages instead)
+- File operations (use patch or bash)
+- Simple data display (just describe it in text)
+
+Good uses:
+- Vega-Lite or other chart library visualizations
+- HTML/CSS demonstrations
+- Interactive widgets or mini-apps
+- SVG graphics
+
+The HTML should be self-contained. You can include inline
+`;
+
+const MIN_HEIGHT = 100;
+const MAX_HEIGHT = 600;
+
+function OutputIframeTool({
+ toolInput,
+ isRunning,
+ toolResult,
+ hasError,
+ executionTime,
+ display,
+}: OutputIframeToolProps) {
+ // Default to expanded for visual content
+ const [isExpanded, setIsExpanded] = useState(true);
+ const [iframeHeight, setIframeHeight] = useState(300);
+ const iframeRef = useRef
") + ? html.replace("", HEIGHT_REPORTER_SCRIPT + "") + : html + HEIGHT_REPORTER_SCRIPT + : undefined; + + // Listen for height messages from iframe + const handleMessage = useCallback((event: MessageEvent) => { + if ( + event.data && + typeof event.data === "object" && + event.data.type === "iframe-height" && + typeof event.data.height === "number" + ) { + // Verify the message is from our iframe + if (iframeRef.current && event.source === iframeRef.current.contentWindow) { + const newHeight = Math.min(Math.max(event.data.height, MIN_HEIGHT), MAX_HEIGHT); + setIframeHeight(newHeight); + } + } + }, []); + + useEffect(() => { + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [handleMessage]); + + // Escape HTML special characters for safe embedding + const escapeHtml = (str: string): string => { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }; + + // Open HTML in new tab with sandbox protection + const handleOpenInNewTab = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!html) return; + + // Create a wrapper HTML page that embeds the content in a sandboxed iframe + // This preserves security even when opened in a new tab + const escapedHtml = escapeHtml(html); + const escapedTitle = escapeHtml(title); + + const wrapperHtml = ` + +
+ +
+ + +
+