shelley: add output_iframe tool for sandboxed HTML visualization

Philip Zeyliger and Shelley created

We'll see if this sticks and is found useful. You can't really
have the LLM output HTML, because it ends up dangerous in the timeline.
But you can embed an iframe. So let's give the LLM a way to do that!

Prompt: I want Shelley to be able to produce HTML output that the user
can see, but for security, I want this to be made safe by using an
iframe. For example, if I ask Shelley to produce a vegalite
visualization, I want the agent to be able to produce sandboxed HTML to
do that. Create a new tool "output_iframe" that should be used for
visual elements if the agent wants to display them, and should be
expanded by default in the HTML tool output. Advise the tool description
to only use this for visualizations like graphs and demonstrations of
HTML, and to use regular messages for chatting with the user.

Add a new tool 'output_iframe' that allows the agent to display HTML content
to the user in a secure sandboxed iframe. This is useful for:
- Vega-Lite and other chart library visualizations
- HTML/CSS demonstrations
- Interactive widgets
- SVG graphics

The tool:
- Takes HTML content and optional title as input
- Returns 'displayed' to the LLM (minimal response)
- Passes HTML to UI via Display field for rendering
- Uses sandbox="allow-scripts" for security (scripts run but can't access parent)

The UI component:
- Renders iframe with srcdoc for the HTML
- Expanded by default (like screenshot tool)
- Styled consistently with other tool components

Tool description explicitly advises using this only for visualizations,
not for regular chat messages.

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

AGENTS.md                              |  11 
claudetool/output_iframe.go            |  87 +++++++
claudetool/output_iframe_test.go       | 106 +++++++++
claudetool/toolset.go                  |   1 
ui/src/components/ChatInterface.tsx    |   2 
ui/src/components/Message.tsx          |  19 +
ui/src/components/OutputIframeTool.tsx | 310 ++++++++++++++++++++++++++++
ui/src/styles.css                      | 161 ++++++++++++++
8 files changed, 692 insertions(+), 5 deletions(-)

Detailed changes

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

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 <script> and <style> tags.
+External resources can be loaded via CDN (e.g., https://cdn.jsdelivr.net/).`
+
+	outputIframeInputSchema = `
+{
+  "type": "object",
+  "required": ["html"],
+  "properties": {
+    "html": {
+      "type": "string",
+      "description": "The HTML content to display. Should be a complete HTML document or fragment."
+    },
+    "title": {
+      "type": "string", 
+      "description": "Optional title describing the visualization"
+    }
+  }
+}
+`
+)
+
+// OutputIframeDisplay is the data passed to the UI for rendering.
+type OutputIframeDisplay struct {
+	Type  string `json:"type"`
+	HTML  string `json:"html"`
+	Title string `json:"title,omitempty"`
+}
+
+func outputIframeRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+	var input struct {
+		HTML  string `json:"html"`
+		Title string `json:"title"`
+	}
+	if err := json.Unmarshal(m, &input); err != nil {
+		return llm.ErrorToolOut(err)
+	}
+
+	if input.HTML == "" {
+		return llm.ErrorfToolOut("html content is required")
+	}
+
+	display := OutputIframeDisplay{
+		Type:  "output_iframe",
+		HTML:  input.HTML,
+		Title: input.Title,
+	}
+
+	return llm.ToolOut{
+		LLMContent: llm.TextContent("displayed"),
+		Display:    display,
+	}
+}

claudetool/output_iframe_test.go 🔗

@@ -0,0 +1,106 @@
+package claudetool
+
+import (
+	"context"
+	"encoding/json"
+	"testing"
+)
+
+func TestOutputIframeRun(t *testing.T) {
+	tests := []struct {
+		name      string
+		input     map[string]any
+		wantErr   bool
+		wantTitle string
+	}{
+		{
+			name: "basic html",
+			input: map[string]any{
+				"html": "<h1>Hello</h1>",
+			},
+			wantErr:   false,
+			wantTitle: "",
+		},
+		{
+			name: "html with title",
+			input: map[string]any{
+				"html":  "<div>Chart</div>",
+				"title": "My Chart",
+			},
+			wantErr:   false,
+			wantTitle: "My Chart",
+		},
+		{
+			name: "empty html",
+			input: map[string]any{
+				"html": "",
+			},
+			wantErr: true,
+		},
+		{
+			name:    "missing html",
+			input:   map[string]any{},
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			inputJSON, err := json.Marshal(tt.input)
+			if err != nil {
+				t.Fatalf("failed to marshal input: %v", err)
+			}
+
+			result := outputIframeRun(context.Background(), inputJSON)
+
+			if tt.wantErr {
+				if result.Error == nil {
+					t.Error("expected error, got nil")
+				}
+				return
+			}
+
+			if result.Error != nil {
+				t.Errorf("unexpected error: %v", result.Error)
+				return
+			}
+
+			if len(result.LLMContent) != 1 || result.LLMContent[0].Text != "displayed" {
+				t.Errorf("expected LLMContent [displayed], got %v", result.LLMContent)
+			}
+
+			display, ok := result.Display.(OutputIframeDisplay)
+			if !ok {
+				t.Errorf("expected Display to be OutputIframeDisplay, got %T", result.Display)
+				return
+			}
+
+			if display.Type != "output_iframe" {
+				t.Errorf("expected Type 'output_iframe', got %q", display.Type)
+			}
+
+			if display.Title != tt.wantTitle {
+				t.Errorf("expected Title %q, got %q", tt.wantTitle, display.Title)
+			}
+
+			if html, _ := tt.input["html"].(string); display.HTML != html {
+				t.Errorf("expected HTML %q, got %q", html, display.HTML)
+			}
+		})
+	}
+}
+
+func TestOutputIframeToolSchema(t *testing.T) {
+	// Verify the tool is properly configured
+	if OutputIframeTool.Name != "output_iframe" {
+		t.Errorf("expected name 'output_iframe', got %q", OutputIframeTool.Name)
+	}
+
+	if OutputIframeTool.Run == nil {
+		t.Error("expected Run function to be set")
+	}
+
+	if len(OutputIframeTool.InputSchema) == 0 {
+		t.Error("expected InputSchema to be set")
+	}
+}

claudetool/toolset.go 🔗

@@ -125,6 +125,7 @@ func NewToolSet(ctx context.Context, cfg ToolSetConfig) *ToolSet {
 		patchTool.Tool(),
 		keywordTool.Tool(),
 		changeDirTool.Tool(),
+		OutputIframeTool,
 	}
 
 	// Add subagent tool if configured

ui/src/components/ChatInterface.tsx 🔗

@@ -24,6 +24,7 @@ import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool";
 import ChangeDirTool from "./ChangeDirTool";
 import BrowserResizeTool from "./BrowserResizeTool";
 import SubagentTool from "./SubagentTool";
+import OutputIframeTool from "./OutputIframeTool";
 import DirectoryPickerModal from "./DirectoryPickerModal";
 
 interface ContextUsageBarProps {
@@ -172,6 +173,7 @@ const TOOL_COMPONENTS: Record<string, React.ComponentType<any>> = {
   change_dir: ChangeDirTool,
   browser_resize: BrowserResizeTool,
   subagent: SubagentTool,
+  output_iframe: OutputIframeTool,
 };
 
 function CoalescedToolCall({

ui/src/components/Message.tsx 🔗

@@ -14,6 +14,7 @@ import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool";
 import ChangeDirTool from "./ChangeDirTool";
 import BrowserResizeTool from "./BrowserResizeTool";
 import SubagentTool from "./SubagentTool";
+import OutputIframeTool from "./OutputIframeTool";
 import UsageDetailModal from "./UsageDetailModal";
 import MessageActionBar from "./MessageActionBar";
 
@@ -375,6 +376,10 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
         if (content.ToolName === "subagent") {
           return <SubagentTool toolInput={content.ToolInput} isRunning={true} />;
         }
+        // Use specialized component for output iframe tool
+        if (content.ToolName === "output_iframe") {
+          return <OutputIframeTool toolInput={content.ToolInput} isRunning={true} />;
+        }
         // Use specialized component for browser console logs tools
         if (
           content.ToolName === "browser_recent_console_logs" ||
@@ -588,6 +593,20 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
           );
         }
 
+        // Use specialized component for output iframe tool
+        if (toolName === "output_iframe") {
+          return (
+            <OutputIframeTool
+              toolInput={toolInput}
+              isRunning={false}
+              toolResult={content.ToolResult}
+              hasError={hasError}
+              executionTime={executionTime}
+              display={content.Display}
+            />
+          );
+        }
+
         // Use specialized component for browser console logs tools
         if (
           toolName === "browser_recent_console_logs" ||

ui/src/components/OutputIframeTool.tsx 🔗

@@ -0,0 +1,310 @@
+import React, { useState, useRef, useEffect, useCallback } from "react";
+import { LLMContent } from "../types";
+
+interface OutputIframeToolProps {
+  // For tool_use (pending state)
+  toolInput?: unknown; // { html: string, title?: string }
+  isRunning?: boolean;
+
+  // For tool_result (completed state)
+  toolResult?: LLMContent[];
+  hasError?: boolean;
+  executionTime?: string;
+  display?: unknown; // OutputIframeDisplay from the Go tool
+}
+
+// Script injected into iframe to report its content height
+const HEIGHT_REPORTER_SCRIPT = `
+<script>
+(function() {
+  function reportHeight() {
+    var height = Math.max(
+      document.body.scrollHeight,
+      document.body.offsetHeight,
+      document.documentElement.scrollHeight,
+      document.documentElement.offsetHeight
+    );
+    window.parent.postMessage({ type: 'iframe-height', height: height }, '*');
+  }
+  // Report on load
+  if (document.readyState === 'complete') {
+    reportHeight();
+  } else {
+    window.addEventListener('load', reportHeight);
+  }
+  // Report after a short delay to catch async content
+  setTimeout(reportHeight, 100);
+  setTimeout(reportHeight, 500);
+  // Report on resize
+  window.addEventListener('resize', reportHeight);
+  // Observe DOM changes
+  if (typeof MutationObserver !== 'undefined') {
+    var observer = new MutationObserver(reportHeight);
+    observer.observe(document.body, { childList: true, subtree: true, attributes: true });
+  }
+})();
+</script>
+`;
+
+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<HTMLIFrameElement>(null);
+
+  // Extract input data
+  const getTitle = (input: unknown): string | undefined => {
+    if (
+      typeof input === "object" &&
+      input !== null &&
+      "title" in input &&
+      typeof input.title === "string"
+    ) {
+      return input.title;
+    }
+    return undefined;
+  };
+
+  const getHtmlFromInput = (input: unknown): string | undefined => {
+    if (
+      typeof input === "object" &&
+      input !== null &&
+      "html" in input &&
+      typeof input.html === "string"
+    ) {
+      return input.html;
+    }
+    return undefined;
+  };
+
+  // Get display data - prefer from display prop, fall back to toolInput
+  const getDisplayData = (): { html?: string; title?: string } => {
+    // First try display prop (from tool result)
+    if (display && typeof display === "object" && display !== null) {
+      const d = display as { html?: string; title?: string };
+      return {
+        html: typeof d.html === "string" ? d.html : undefined,
+        title: typeof d.title === "string" ? d.title : undefined,
+      };
+    }
+    // Fall back to toolInput
+    return {
+      html: getHtmlFromInput(toolInput),
+      title: getTitle(toolInput),
+    };
+  };
+
+  const displayData = getDisplayData();
+  const title = displayData.title || "HTML Output";
+  const html = displayData.html;
+
+  // Inject height reporter script into HTML
+  const htmlWithHeightReporter = html
+    ? html.includes("</body>")
+      ? html.replace("</body>", HEIGHT_REPORTER_SCRIPT + "</body>")
+      : 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, "&amp;")
+      .replace(/</g, "&lt;")
+      .replace(/>/g, "&gt;")
+      .replace(/"/g, "&quot;")
+      .replace(/'/g, "&#39;");
+  };
+
+  // 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 = `<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="UTF-8">
+  <title>${escapedTitle}</title>
+  <style>
+    * { margin: 0; padding: 0; box-sizing: border-box; }
+    html, body { height: 100%; background: #f5f5f5; }
+    iframe { 
+      width: 100%; 
+      height: 100%; 
+      border: none;
+      background: white;
+    }
+  </style>
+</head>
+<body>
+  <iframe sandbox="allow-scripts" srcdoc="${escapedHtml}"></iframe>
+</body>
+</html>`;
+
+    const blob = new Blob([wrapperHtml], { type: "text/html" });
+    const url = URL.createObjectURL(blob);
+    window.open(url, "_blank");
+    // Clean up the URL after a delay
+    setTimeout(() => URL.revokeObjectURL(url), 1000);
+  };
+
+  const isComplete = !isRunning && toolResult !== undefined;
+
+  return (
+    <div
+      className="output-iframe-tool"
+      data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}
+    >
+      <div className="output-iframe-tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+        <div className="output-iframe-tool-summary">
+          <span className={`output-iframe-tool-emoji ${isRunning ? "running" : ""}`}>✨</span>
+          <span className="output-iframe-tool-title">{title}</span>
+          {isComplete && hasError && <span className="output-iframe-tool-error">✗</span>}
+          {isComplete && !hasError && <span className="output-iframe-tool-success">✓</span>}
+        </div>
+        <div className="output-iframe-tool-actions">
+          {isComplete && !hasError && html && (
+            <button
+              className="output-iframe-tool-open-btn"
+              onClick={handleOpenInNewTab}
+              aria-label="Open in new tab"
+              title="Open in new tab"
+            >
+              <svg
+                width="14"
+                height="14"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                strokeWidth="2"
+                strokeLinecap="round"
+                strokeLinejoin="round"
+              >
+                <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
+                <polyline points="15 3 21 3 21 9" />
+                <line x1="10" y1="14" x2="21" y2="3" />
+              </svg>
+            </button>
+          )}
+          <button
+            className="output-iframe-tool-toggle"
+            onClick={(e) => {
+              e.stopPropagation();
+              setIsExpanded(!isExpanded);
+            }}
+            aria-label={isExpanded ? "Collapse" : "Expand"}
+            aria-expanded={isExpanded}
+          >
+            <svg
+              width="12"
+              height="12"
+              viewBox="0 0 12 12"
+              fill="none"
+              xmlns="http://www.w3.org/2000/svg"
+              style={{
+                transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+                transition: "transform 0.2s",
+              }}
+            >
+              <path
+                d="M4.5 3L7.5 6L4.5 9"
+                stroke="currentColor"
+                strokeWidth="1.5"
+                strokeLinecap="round"
+                strokeLinejoin="round"
+              />
+            </svg>
+          </button>
+        </div>
+      </div>
+
+      {isExpanded && (
+        <div className="output-iframe-tool-details">
+          {isComplete && !hasError && htmlWithHeightReporter && (
+            <div className="output-iframe-tool-section">
+              {executionTime && (
+                <div className="output-iframe-tool-label">
+                  <span>Output:</span>
+                  <span className="output-iframe-tool-time">{executionTime}</span>
+                </div>
+              )}
+              <div className="output-iframe-container">
+                <iframe
+                  ref={iframeRef}
+                  srcDoc={htmlWithHeightReporter}
+                  sandbox="allow-scripts"
+                  title={title}
+                  style={{
+                    width: "100%",
+                    height: `${iframeHeight}px`,
+                    border: "1px solid var(--border-color, #e5e7eb)",
+                    borderRadius: "4px",
+                    backgroundColor: "white",
+                  }}
+                />
+              </div>
+            </div>
+          )}
+
+          {isComplete && hasError && (
+            <div className="output-iframe-tool-section">
+              <div className="output-iframe-tool-label">
+                <span>Error:</span>
+                {executionTime && <span className="output-iframe-tool-time">{executionTime}</span>}
+              </div>
+              <pre className="output-iframe-tool-error-message">
+                {toolResult && toolResult[0]?.Text
+                  ? toolResult[0].Text
+                  : "Failed to display HTML content"}
+              </pre>
+            </div>
+          )}
+
+          {isRunning && (
+            <div className="output-iframe-tool-section">
+              <div className="output-iframe-tool-label">Preparing HTML output...</div>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default OutputIframeTool;

ui/src/styles.css 🔗

@@ -1583,6 +1583,167 @@ button {
   color: var(--error-text);
 }
 
+/* Output Iframe Tool */
+.output-iframe-tool {
+  background: var(--gray-100);
+  border-radius: 0.5rem;
+  margin: 0.5rem 0;
+  width: 100%;
+}
+
+.dark .output-iframe-tool {
+  background: var(--gray-800);
+}
+
+.output-iframe-tool-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.75rem 1rem;
+  cursor: pointer;
+  user-select: none;
+}
+
+.output-iframe-tool-header:hover {
+  background: rgba(0, 0, 0, 0.02);
+  border-radius: 0.5rem;
+}
+
+.dark .output-iframe-tool-header:hover {
+  background: rgba(255, 255, 255, 0.02);
+}
+
+.output-iframe-tool-summary {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  flex: 1;
+  min-width: 0;
+}
+
+.output-iframe-tool-emoji {
+  font-size: 1rem;
+  flex-shrink: 0;
+}
+
+.output-iframe-tool-emoji.running {
+  animation: rotate 1s linear infinite;
+}
+
+.output-iframe-tool-title {
+  font-family: var(--font-mono);
+  font-size: 0.875rem;
+  color: var(--text-primary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-weight: 500;
+}
+
+.output-iframe-tool-success {
+  color: var(--success-text);
+  font-weight: 600;
+  margin-left: 0.5rem;
+}
+
+.output-iframe-tool-error {
+  color: var(--error-text);
+  font-weight: 600;
+  margin-left: 0.5rem;
+}
+
+.output-iframe-tool-actions {
+  display: flex;
+  align-items: center;
+  gap: 0.25rem;
+  flex-shrink: 0;
+}
+
+.output-iframe-tool-open-btn {
+  background: none;
+  border: none;
+  padding: 0.25rem;
+  cursor: pointer;
+  color: var(--text-secondary);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 4px;
+}
+
+.output-iframe-tool-open-btn:hover {
+  color: var(--text-primary);
+  background: var(--gray-200);
+}
+
+.dark .output-iframe-tool-open-btn:hover {
+  background: var(--gray-700);
+}
+
+.output-iframe-tool-toggle {
+  background: none;
+  border: none;
+  padding: 0.25rem;
+  cursor: pointer;
+  color: var(--text-secondary);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.output-iframe-tool-toggle:hover {
+  color: var(--text-primary);
+}
+
+.output-iframe-tool-details {
+  padding: 0 1rem 1rem 1rem;
+}
+
+.output-iframe-tool-section {
+  margin-top: 0.5rem;
+}
+
+.output-iframe-tool-label {
+  font-size: 0.75rem;
+  font-weight: 500;
+  color: var(--text-secondary);
+  margin-bottom: 0.5rem;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.output-iframe-tool-time {
+  font-family: var(--font-mono);
+  font-size: 0.75rem;
+  color: var(--text-tertiary);
+}
+
+.output-iframe-container {
+  width: 100%;
+  overflow: hidden;
+  border-radius: 0.25rem;
+}
+
+.output-iframe-container iframe {
+  display: block;
+}
+
+.output-iframe-tool-error-message {
+  font-family: var(--font-mono);
+  font-size: 0.875rem;
+  background: var(--error-bg);
+  border: 1px solid var(--error-border);
+  border-radius: 0.25rem;
+  padding: 0.75rem;
+  margin: 0;
+  overflow-x: auto;
+  white-space: pre-wrap;
+  word-break: break-word;
+  color: var(--error-text);
+}
+
 /* Message Input */
 .message-input-container {
   flex: 0 0 auto;