sketch: add support for 'external' message types

- adds a new CodingAgentMessageType for loop.AgentMessage
- adds an new /external handler to loophttp.go
- modifies Agent to pass the .TextContent of ExternalMessage into the convo
  as though it came from the user.
- adds sketch-external-message web component, with a template for
  github workflow run events specifically.
- adds demos for sketch-external-message
diff --git a/cmd/go2ts/go2ts.go b/cmd/go2ts/go2ts.go
index a4015e0..956751c 100644
--- a/cmd/go2ts/go2ts.go
+++ b/cmd/go2ts/go2ts.go
@@ -50,6 +50,7 @@
 			loop.PortMessageType,
 			loop.CompactMessageType,
 			loop.SlugMessageType,
+			loop.ExternalMessageType,
 		},
 	)
 
diff --git a/loop/agent.go b/loop/agent.go
index 8339369..0877a5a 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -167,21 +167,25 @@
 
 	// ModelName returns the name of the model the agent is using.
 	ModelName() string
+
+	// ExternalMessage enqueues an external message to the agent and returns immediately.
+	ExternalMessage(ctx context.Context, msg ExternalMessage) error
 }
 
 type CodingAgentMessageType string
 
 const (
-	UserMessageType    CodingAgentMessageType = "user"
-	AgentMessageType   CodingAgentMessageType = "agent"
-	ErrorMessageType   CodingAgentMessageType = "error"
-	BudgetMessageType  CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
-	ToolUseMessageType CodingAgentMessageType = "tool"
-	CommitMessageType  CodingAgentMessageType = "commit"  // for displaying git commits
-	AutoMessageType    CodingAgentMessageType = "auto"    // for automated notifications like autoformatting
-	CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
-	PortMessageType    CodingAgentMessageType = "port"    // for port monitoring events
-	SlugMessageType    CodingAgentMessageType = "slug"    // for slug updates
+	UserMessageType     CodingAgentMessageType = "user"
+	AgentMessageType    CodingAgentMessageType = "agent"
+	ErrorMessageType    CodingAgentMessageType = "error"
+	BudgetMessageType   CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
+	ToolUseMessageType  CodingAgentMessageType = "tool"
+	CommitMessageType   CodingAgentMessageType = "commit"   // for displaying git commits
+	AutoMessageType     CodingAgentMessageType = "auto"     // for automated notifications like autoformatting
+	CompactMessageType  CodingAgentMessageType = "compact"  // for conversation compaction notifications
+	PortMessageType     CodingAgentMessageType = "port"     // for port monitoring events
+	SlugMessageType     CodingAgentMessageType = "slug"     // for slug updates
+	ExternalMessageType CodingAgentMessageType = "external" // for external notifications
 
 	cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
 )
@@ -191,12 +195,13 @@
 	// EndOfTurn indicates that the AI is done working and is ready for the next user input.
 	EndOfTurn bool `json:"end_of_turn"`
 
-	Content    string `json:"content"`
-	ToolName   string `json:"tool_name,omitempty"`
-	ToolInput  string `json:"input,omitempty"`
-	ToolResult string `json:"tool_result,omitempty"`
-	ToolError  bool   `json:"tool_error,omitempty"`
-	ToolCallId string `json:"tool_call_id,omitempty"`
+	Content         string           `json:"content"`
+	ExternalMessage *ExternalMessage `json:"external_message,omitempty"`
+	ToolName        string           `json:"tool_name,omitempty"`
+	ToolInput       string           `json:"input,omitempty"`
+	ToolResult      string           `json:"tool_result,omitempty"`
+	ToolError       bool             `json:"tool_error,omitempty"`
+	ToolCallId      string           `json:"tool_call_id,omitempty"`
 
 	// ToolCalls is a list of all tool calls requested in this message (name and input pairs)
 	ToolCalls []ToolCall `json:"tool_calls,omitempty"`
@@ -480,6 +485,18 @@
 	outstandingToolCalls map[string]string
 }
 
+// ExternalMessage implements CodingAgent.
+// TODO: Debounce and/or coalesce these messages so they're less disruptive to the conversation.
+func (a *Agent) ExternalMessage(ctx context.Context, msg ExternalMessage) error {
+	agentMsg := AgentMessage{
+		Type:            ExternalMessageType,
+		ExternalMessage: &msg,
+	}
+	a.pushToOutbox(ctx, agentMsg)
+	a.inbox <- msg.TextContent
+	return nil
+}
+
 // TokenContextWindow implements CodingAgent.
 func (a *Agent) TokenContextWindow() int {
 	return a.config.Service.TokenContextWindow()
@@ -2729,3 +2746,11 @@
 	}
 	return ""
 }
+
+// ExternalMsg represents a message from a source external to the agent/user conversation,
+// such as the outcome of a github workflow run.
+type ExternalMessage struct {
+	MessageType string `json:"message_type"`
+	Body        any    `json:"body"`
+	TextContent string `json:"text_content"`
+}
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index dacc4f6..856932d 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -706,6 +706,35 @@
 		w.WriteHeader(http.StatusOK)
 	})
 
+	// Handler for POST /external - e.g. where you send messages about e.g. github workflow
+	// outcomes and other external events that the agent wouldn't otherwise be aware of.
+	s.mux.HandleFunc("/external", func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodPost {
+			httpError(w, r, "Method not allowed", http.StatusMethodNotAllowed)
+			return
+		}
+		var msg loop.ExternalMessage
+
+		decoder := json.NewDecoder(r.Body)
+		if err := decoder.Decode(&msg); err != nil {
+			httpError(w, r, "Invalid request body: "+err.Error(), http.StatusBadRequest)
+			return
+		}
+		defer r.Body.Close()
+
+		if msg.TextContent == "" {
+			httpError(w, r, "Message cannot be empty", http.StatusBadRequest)
+			return
+		}
+
+		if err := agent.ExternalMessage(r.Context(), msg); err != nil {
+			httpError(w, r, "agent ExternalMessage error: "+err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		w.WriteHeader(http.StatusOK)
+	})
+
 	// Handler for POST /upload - uploads a file to /tmp
 	s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
 		if r.Method != http.MethodPost {
diff --git a/loop/server/loophttp_test.go b/loop/server/loophttp_test.go
index fa591b2..8d4583e 100644
--- a/loop/server/loophttp_test.go
+++ b/loop/server/loophttp_test.go
@@ -38,6 +38,11 @@
 	model                    string
 }
 
+// ExternalMessage implements loop.CodingAgent.
+func (m *mockAgent) ExternalMessage(ctx context.Context, msg loop.ExternalMessage) error {
+	panic("unimplemented")
+}
+
 // TokenContextWindow implements loop.CodingAgent.
 func (m *mockAgent) TokenContextWindow() int {
 	return 200000
diff --git a/webui/package-lock.json b/webui/package-lock.json
index b5b0478..ce9e06f 100644
--- a/webui/package-lock.json
+++ b/webui/package-lock.json
@@ -9,6 +9,7 @@
       "version": "1.0.0",
       "license": "ISC",
       "dependencies": {
+        "@octokit/webhooks-types": "^7.6.1",
         "@tailwindcss/cli": "^4.1.10",
         "@tailwindcss/container-queries": "^0.1.1",
         "@tailwindcss/vite": "^4.1.11",
@@ -1427,6 +1428,12 @@
         "node": ">= 8"
       }
     },
+    "node_modules/@octokit/webhooks-types": {
+      "version": "7.6.1",
+      "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.6.1.tgz",
+      "integrity": "sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==",
+      "license": "MIT"
+    },
     "node_modules/@open-draft/deferred-promise": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
diff --git a/webui/package.json b/webui/package.json
index c2f15aa..90fb3fd 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -26,6 +26,7 @@
     "test:playwright": "playwright test -c playwright-ct.config.ts"
   },
   "dependencies": {
+    "@octokit/webhooks-types": "^7.6.1",
     "@tailwindcss/cli": "^4.1.10",
     "@tailwindcss/container-queries": "^0.1.1",
     "@tailwindcss/vite": "^4.1.11",
diff --git a/webui/src/types.ts b/webui/src/types.ts
index e4ee48c..d0ed5de 100644
--- a/webui/src/types.ts
+++ b/webui/src/types.ts
@@ -1,6 +1,12 @@
 // Auto-generated by sketch.dev/cmd/go2ts.go
 // DO NOT EDIT. This file is automatically generated.
 
+export interface ExternalMessage {
+	message_type: string;
+	body: any;
+	text_content: string;
+}
+
 export interface ToolCall {
 	name: string;
 	input: string;
@@ -29,6 +35,7 @@
 	type: CodingAgentMessageType;
 	end_of_turn: boolean;
 	content: string;
+	external_message?: ExternalMessage | null;
 	tool_name?: string;
 	input?: string;
 	tool_result?: string;
@@ -166,6 +173,6 @@
 	subject: string;
 }
 
-export type CodingAgentMessageType = 'user' | 'agent' | 'error' | 'budget' | 'tool' | 'commit' | 'auto' | 'port' | 'compact' | 'slug';
+export type CodingAgentMessageType = 'user' | 'agent' | 'error' | 'budget' | 'tool' | 'commit' | 'auto' | 'port' | 'compact' | 'slug' | 'external';
 
 export type Duration = number;
diff --git a/webui/src/web-components/demo/demo-framework/demo-runner.ts b/webui/src/web-components/demo/demo-framework/demo-runner.ts
index 680651b..2871929 100644
--- a/webui/src/web-components/demo/demo-framework/demo-runner.ts
+++ b/webui/src/web-components/demo/demo-framework/demo-runner.ts
@@ -96,6 +96,7 @@
       "sketch-diff-range-picker",
       "sketch-timeline",
       "sketch-timeline-message",
+      "sketch-external-message",
       "sketch-todo-panel",
       "sketch-tool-calls",
       "sketch-view-mode-select",
diff --git a/webui/src/web-components/demo/sketch-external-message.demo.ts b/webui/src/web-components/demo/sketch-external-message.demo.ts
new file mode 100644
index 0000000..cdaa86d
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-external-message.demo.ts
@@ -0,0 +1,120 @@
+import { DemoModule } from "./demo-framework/types";
+import { demoUtils } from "./demo-fixtures/index";
+import { SketchExternalMessage } from "../sketch-external-message";
+
+const demo: DemoModule = {
+  title: "Sketch External Message Demo",
+  description:
+    "Demonstration of external message components for various external message types",
+  imports: ["../sketch-external-message.ts"],
+
+  setup: async (container: HTMLElement) => {
+    const section = demoUtils.createDemoSection(
+      "External Message Components",
+      "Shows different external message components for various message types",
+    );
+
+    // external messages
+    const externalMessages = [
+      {
+        name: "github_workflow_run: completed, success",
+        message_type: "github_workflow_run",
+        body: {
+          workflow: {
+            name: "go_tests",
+          },
+          workflow_run: {
+            id: 123456789,
+            status: "completed",
+            conclusion: "success",
+            head_branch: "user/sketch/slug-name",
+            head_commit: "abc123",
+            html_url: "https://github.com/orgs/your-org/actions/runs/123456789",
+          },
+        },
+      },
+      {
+        name: "github_workflow_run: completed, failed",
+        message_type: "github_workflow_run",
+        body: {
+          workflow: {
+            name: "go_tests",
+          },
+          workflow_run: {
+            id: 123456789,
+            status: "completed",
+            conclusion: "failure",
+            head_branch: "user/sketch/slug-name",
+            head_commit: "abc123",
+            html_url: "https://github.com/orgs/your-org/actions/runs/123456789",
+          },
+        },
+      },
+      {
+        name: "github_workflow_run: queued",
+        message_type: "github_workflow_run",
+        body: {
+          workflow: {
+            name: "go_tests",
+          },
+          workflow_run: {
+            id: 123456789,
+            status: "queued",
+            head_branch: "user/sketch/slug-name",
+            head_commit: "abc123",
+            html_url: "https://github.com/orgs/your-org/actions/runs/123456789",
+          },
+        },
+      },
+      {
+        name: "github_workflow_run: in_progress",
+        message_type: "github_workflow_run",
+        body: {
+          workflow: {
+            name: "go_tests",
+          },
+          workflow_run: {
+            id: 123456789,
+            status: "in_progress",
+            head_branch: "user/sketch/slug-name",
+            head_commit: "abc123",
+            html_url: "https://github.com/orgs/your-org/actions/runs/123456789",
+          },
+        },
+      },
+    ];
+
+    // Create tool cards for each tool call
+    externalMessages.forEach((msg) => {
+      const msgSection = document.createElement("div");
+      msgSection.style.marginBottom = "2rem";
+      msgSection.style.border = "1px solid #eee";
+      msgSection.style.borderRadius = "8px";
+      msgSection.style.padding = "1rem";
+
+      const header = document.createElement("h3");
+      header.textContent = `Message: ${msg.name}`;
+      header.style.marginTop = "0";
+      header.style.marginBottom = "1rem";
+      header.style.color = "var(--demo-fixture-text-color)";
+      msgSection.appendChild(header);
+      const msgEl = document.createElement(
+        "sketch-external-message",
+      ) as SketchExternalMessage;
+
+      msgEl.message = {
+        message_type: msg.message_type,
+        body: msg.body,
+        text_content: `Workflow run ${msg.body.workflow_run.id} completed with status ${msg.body.workflow_run.status} and conclusion ${msg.body.workflow_run.conclusion}.`,
+      };
+      msgEl.open = true;
+
+      msgSection.appendChild(msgEl);
+      section.appendChild(msgSection);
+    });
+
+    container.appendChild(section);
+  },
+};
+
+export default demo;
diff --git a/webui/src/web-components/sketch-external-message.ts b/webui/src/web-components/sketch-external-message.ts
new file mode 100644
index 0000000..1df27d8
--- /dev/null
+++ b/webui/src/web-components/sketch-external-message.ts
@@ -0,0 +1,128 @@
+import { html, TemplateResult } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { ExternalMessage } from "../types";
+import { SketchTailwindElement } from "./sketch-tailwind-element";
+import type { WorkflowRunEvent } from "@octokit/webhooks-types";
+
+@customElement("sketch-external-message")
+export class SketchExternalMessage extends SketchTailwindElement {
+  @property() message: ExternalMessage | null = null;
+  @property() open: boolean;
+  @property() summaryContent: TemplateResult | string = "";
+  @property() detailsContent: TemplateResult | string = "";
+  @state() detailsVisible: boolean = false;
+
+  _toggleDetails(e: Event) {
+    e.stopPropagation();
+    this.detailsVisible = !this.detailsVisible;
+  }
+
+  constructor() {
+    super();
+  }
+
+  render() {
+    if (this.message?.message_type === "github_workflow_run") {
+      const run = this.message.body as WorkflowRunEvent;
+      this.summaryContent = html`<svg
+          class="inline-block w-4 h-4"
+          viewBox="0 0 16 16"
+          width="16"
+          height="16"
+        >
+          <path
+            fill="currentColor"
+            d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"
+          ></path>
+        </svg>
+        <span
+          class="inline-flex items-center px-1.5 py-1
+        ${run.workflow_run.conclusion === "success"
+            ? "bg-green-600/10 text-green-600"
+            : run.workflow_run.conclusion === "failure"
+              ? "bg-red-600/10 text-red-600"
+              : "bg-gray-600/10 text-gray-600"}
+          rounded-md text-xs flex-shrink-0 transition-colors"
+          >${run.workflow.name} -
+          ${run.workflow_run.conclusion || run.workflow_run.status}</span
+        >
+        on ${run.workflow_run.head_branch} at ${run.workflow_run.head_commit}`;
+
+      this.detailsContent = html`
+        <div class="flex items-center gap-2">
+          <span class="text-gray-700 dark:text-gray-300">Workflow ID:</span>
+          <span class="font-mono font-medium text-gray-800 dark:text-gray-200">${run.workflow_run.id}</span>
+        </div>
+        <div class="flex items-center gap-2">
+          bg-gray-600/10 text-gray-600
+        {{end}}
+          rounded-md text-xs flex-shrink-0 transition-colors'><a target="_blank"
+            rel="noopener noreferrer"href="${run.workflow_run.html_url}">${run.workflow.name} - ${run.workflow_run.conclusion}</a>
+            on ${run.workflow_run.head_branch} at ${run.workflow_run.head_commit}</span>
+            </span>`;
+
+      this.detailsContent = html`
+        <div class="flex items-center gap-2">
+          <span class="text-gray-700 dark:text-gray-300">Workflow Run ID:</span>
+          <span class="font-mono font-medium text-gray-800 dark:text-gray-200"
+            >${run.workflow_run.id}</span
+          >
+        </div>
+        <div class="flex items-center gap-2">
+          <span class="text-gray-700 dark:text-gray-300">Status:</span>
+          <span class="font-mono font-medium text-gray-800 dark:text-gray-200"
+            >${run.workflow_run.status}</span
+          >
+        </div>
+        <div class="flex items-center gap-2">
+          <span class="text-gray-700 dark:text-gray-300">Conclusion:</span>
+          <span class="font-mono font-medium text-gray-800 dark:text-gray-200"
+            >${run.workflow_run.conclusion}</span
+          >
+        </div>
+        <div class="flex items-center gap-2">
+          <span class="text-gray-700 dark:text-gray-300">Run URL:</span>
+          <a
+            class="text-blue-600 dark:text-blue-400 hover:underline"
+            href="${run.workflow_run.html_url}"
+            target="_blank"
+            rel="noopener noreferrer"
+          >
+            ${run.workflow_run.html_url}
+          </a>
+        </div>
+      `;
+    }
+
+    return html`
+      <div class="block max-w-full w-full box-border overflow-hidden">
+        <div class="flex flex-col w-full">
+          <div
+            class="flex w-full box-border py-1.5 px-2 pl-3 items-center gap-2 cursor-pointer rounded relative overflow-hidden flex-wrap hover:bg-black/[0.02] dark:hover:bg-white/[0.05]"
+            @click=${this._toggleDetails}
+          >
+            <span
+              class="whitespace-nowrap flex-grow flex-shrink text-gray-700 dark:text-gray-300 font-mono text-xs px-1 min-w-[50px] max-w-[calc(100%-150px)] inline-block"
+              >${this.summaryContent}</span
+            >
+          </div>
+          <div
+            class="${this.detailsVisible
+              ? "block"
+              : "hidden"} p-2 bg-black/[0.02] dark:bg-white/[0.05] mt-px border-t border-black/[0.05] dark:border-white/[0.1] font-mono text-xs text-gray-800 dark:text-gray-200 rounded-b max-w-full w-full box-border overflow-hidden"
+          >
+            ${this.detailsContent
+              ? html`<div class="mb-2">${this.detailsContent}</div>`
+              : ""}
+          </div>
+        </div>
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-notification-message": SketchExternalMessage;
+  }
+}
diff --git a/webui/src/web-components/sketch-timeline-message.ts b/webui/src/web-components/sketch-timeline-message.ts
index abbb329..9a0b78a 100644
--- a/webui/src/web-components/sketch-timeline-message.ts
+++ b/webui/src/web-components/sketch-timeline-message.ts
@@ -5,6 +5,9 @@
 import { marked, MarkedOptions, Renderer, Tokens } from "marked";
 import type mermaid from "mermaid";
 import DOMPurify from "dompurify";
+import "./sketch-tool-calls";
+import "./sketch-external-message";
+import { SketchTailwindElement } from "./sketch-tailwind-element";
 
 // Mermaid is loaded dynamically - see loadMermaid() function
 declare global {
@@ -54,8 +57,6 @@
 
   return mermaidLoadPromise;
 }
-import "./sketch-tool-calls";
-import { SketchTailwindElement } from "./sketch-tailwind-element";
 
 @customElement("sketch-timeline-message")
 export class SketchTimelineMessage extends SketchTailwindElement {
@@ -927,12 +928,15 @@
       .join(" ");
 
     const messageContentClasses = [
-      "relative px-2.5 py-1.5 rounded-xl shadow-sm min-w-min",
+      "relative px-2.5 py-1.5 min-w-min",
       // User message styling
       this.message?.type === "user"
-        ? "bg-blue-500 text-white rounded-br-sm"
-        : // Agent/tool/error message styling
-          "bg-gray-100 dark:bg-gray-800 text-black dark:text-gray-100 rounded-bl-sm",
+        ? "rounded-xl shadow-sm bg-blue-500 text-white rounded-br-sm"
+        : this.message?.type === "agent" // Agent/tool/error message styling
+          ? "rounded-xl shadow-sm bg-gray-100 dark:bg-gray-800 text-black dark:text-gray-100 rounded-bl-sm"
+          : this.message?.type === "external" // External message styling
+            ? "bg-white dark:bg-gray-800 text-black dark:text-gray-100"
+            : "", // default styling for other types
     ]
       .filter(Boolean)
       .join(" ");
@@ -1135,6 +1139,16 @@
                   `
                 : ""}
 
+              <!-- External messages -->
+              ${this.message?.type === "external"
+                ? html`
+                    <sketch-external-message
+                      .message=${this.message?.external_message}
+                      .open=${this.open}
+                    ></sketch-external-message>
+                  `
+                : ""}
+
               <!-- Commits section -->
               ${this.message?.commits
                 ? html`