diff --git a/dotnet/src/Canvas.cs b/dotnet/src/Canvas.cs
index 6bf8be984e..1369b79ea9 100644
--- a/dotnet/src/Canvas.cs
+++ b/dotnet/src/Canvas.cs
@@ -33,6 +33,13 @@ public sealed class CanvasDeclaration
[JsonPropertyName("description")]
public string Description { get; set; } = string.Empty;
+ ///
+ /// Optional PNG path for the canvas icon. For extensions, the runtime resolves
+ /// relative paths relative to extension.mjs.
+ ///
+ [JsonPropertyName("icon")]
+ public string? Icon { get; set; }
+
/// JSON Schema for the input payload accepted by canvas.open.
[JsonPropertyName("inputSchema")]
public JsonElement? InputSchema { get; set; }
diff --git a/dotnet/test/Unit/CanvasDeclarationTests.cs b/dotnet/test/Unit/CanvasDeclarationTests.cs
new file mode 100644
index 0000000000..2db5b768b6
--- /dev/null
+++ b/dotnet/test/Unit/CanvasDeclarationTests.cs
@@ -0,0 +1,62 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------------------------------------------*/
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Xunit;
+
+namespace GitHub.Copilot.Test.Unit;
+
+public class CanvasDeclarationTests
+{
+ [Fact]
+ public void SerializesIconPath()
+ {
+ var declaration = new CanvasDeclaration
+ {
+ Id = "counter",
+ DisplayName = "Counter",
+ Description = "Count things",
+ Icon = "icons/counter.png",
+ };
+ var serialized = JsonSerializer.Serialize(declaration, CanvasDeclarationTestJsonContext.Default.CanvasDeclaration);
+ using var doc = JsonDocument.Parse(serialized);
+
+ Assert.Equal("icons/counter.png", doc.RootElement.GetProperty("icon").GetString());
+ }
+
+ [Fact]
+ public void RoundtripsIconPath()
+ {
+ const string json = """
+ {"id":"counter","displayName":"Counter","description":"Count things","icon":"icons/counter.png"}
+ """;
+ var declaration = JsonSerializer.Deserialize(json, CanvasDeclarationTestJsonContext.Default.CanvasDeclaration);
+ Assert.NotNull(declaration);
+ Assert.Equal("icons/counter.png", declaration.Icon);
+ var serialized = JsonSerializer.Serialize(declaration, CanvasDeclarationTestJsonContext.Default.CanvasDeclaration);
+ using var doc = JsonDocument.Parse(serialized);
+
+ Assert.Equal("icons/counter.png", doc.RootElement.GetProperty("icon").GetString());
+ }
+
+ [Fact]
+ public void OmitsUnspecifiedIcon()
+ {
+ var declaration = new CanvasDeclaration
+ {
+ Id = "counter",
+ DisplayName = "Counter",
+ Description = "Count things",
+ };
+ var serialized = JsonSerializer.Serialize(declaration, CanvasDeclarationTestJsonContext.Default.CanvasDeclaration);
+ using var doc = JsonDocument.Parse(serialized);
+
+ Assert.False(doc.RootElement.TryGetProperty("icon", out _));
+ }
+}
+
+[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
+[JsonSerializable(typeof(CanvasDeclaration))]
+internal partial class CanvasDeclarationTestJsonContext : JsonSerializerContext;
diff --git a/go/canvas.go b/go/canvas.go
index e31598bb1e..8f9fd81937 100644
--- a/go/canvas.go
+++ b/go/canvas.go
@@ -24,6 +24,9 @@ type CanvasDeclaration struct {
DisplayName string `json:"displayName"`
// Description is a short, single-sentence description shown to the agent in canvas catalogs.
Description string `json:"description"`
+ // Icon is an optional PNG path for the canvas icon. For extensions, the runtime
+ // resolves relative paths relative to extension.mjs.
+ Icon *string `json:"icon,omitempty"`
// InputSchema is the JSON Schema for the `input` payload accepted by `canvas.open`.
InputSchema map[string]any `json:"inputSchema,omitzero"`
// Actions are the agent-callable actions this canvas exposes.
diff --git a/go/canvas_test.go b/go/canvas_test.go
index 3fdd2facc4..684437795c 100644
--- a/go/canvas_test.go
+++ b/go/canvas_test.go
@@ -67,6 +67,64 @@ func TestCanvasDeclaration_OmitsEmptyActions(t *testing.T) {
}
}
+func TestCanvasDeclaration_RoundtripsIcon(t *testing.T) {
+ data := []byte(`{"id":"counter","displayName":"Counter","description":"Count things","icon":"icons/counter.png"}`)
+ var decl CanvasDeclaration
+ if err := json.Unmarshal(data, &decl); err != nil {
+ t.Fatalf("unmarshal declaration failed: %v", err)
+ }
+ if decl.Icon == nil || *decl.Icon != "icons/counter.png" {
+ t.Fatalf("expected icon path on declaration, got %v", decl.Icon)
+ }
+ encoded, err := json.Marshal(decl)
+ if err != nil {
+ t.Fatalf("marshal declaration failed: %v", err)
+ }
+ var decoded map[string]any
+ if err := json.Unmarshal(encoded, &decoded); err != nil {
+ t.Fatalf("unmarshal payload failed: %v", err)
+ }
+ if decoded["icon"] != "icons/counter.png" {
+ t.Fatalf("expected icon path to be preserved, got %v", decoded["icon"])
+ }
+}
+
+func TestCanvasDeclaration_SerializesIcon(t *testing.T) {
+ icon := "icons/counter.png"
+ decl := CanvasDeclaration{
+ ID: "counter",
+ DisplayName: "Counter",
+ Description: "Count things",
+ Icon: &icon,
+ }
+ data, err := json.Marshal(decl)
+ if err != nil {
+ t.Fatalf("marshal failed: %v", err)
+ }
+ var decoded map[string]any
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("unmarshal failed: %v", err)
+ }
+ if decoded["icon"] != icon {
+ t.Fatalf("expected icon path to be preserved, got %v", decoded["icon"])
+ }
+}
+
+func TestCanvasDeclaration_OmitsUnspecifiedIcon(t *testing.T) {
+ decl := CanvasDeclaration{ID: "counter", DisplayName: "Counter", Description: "Count things"}
+ data, err := json.Marshal(decl)
+ if err != nil {
+ t.Fatalf("marshal failed: %v", err)
+ }
+ var decoded map[string]any
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("unmarshal failed: %v", err)
+ }
+ if _, present := decoded["icon"]; present {
+ t.Fatalf("icon should be omitted when nil, got %v", decoded["icon"])
+ }
+}
+
func TestCanvasHandlerDefaults_OnAction_ReturnsNoHandler(t *testing.T) {
d := CanvasHandlerDefaults{}
_, err := d.OnAction(context.Background(), rpc.CanvasProviderInvokeActionRequest{})
diff --git a/nodejs/README.md b/nodejs/README.md
index a6e577be93..0fd5b776ea 100644
--- a/nodejs/README.md
+++ b/nodejs/README.md
@@ -96,6 +96,30 @@ tool name is `-`. For `availableTools` and
the raw `mcp:-` form. For `customAgents[].tools` and
`defaultAgent.excludedTools`, use `-` directly.
+## Canvas icons
+
+Canvas authoring is experimental. Set the optional `icon` field on
+`createCanvas` to a PNG file path. For extensions, the runtime resolves relative
+paths relative to `extension.mjs`, not the current working directory. Include
+the PNG with your extension; the SDK forwards the path unchanged.
+
+```typescript
+import { createCanvas, joinSession } from "@github/copilot-sdk/extension";
+
+const counter = createCanvas({
+ id: "counter",
+ displayName: "Counter",
+ description: "Count things",
+ icon: "icons/counter.png",
+ open: () => ({ url: "https://example.com/counter" }),
+});
+
+await joinSession({ canvases: [counter] });
+```
+
+Omit `icon` to declare a canvas without a custom icon. The icon belongs to the
+canvas declaration, not an individual open result.
+
## API Reference
### CopilotClient
diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts
index aeb1f00ec4..0624379777 100644
--- a/nodejs/src/canvas.ts
+++ b/nodejs/src/canvas.ts
@@ -67,6 +67,11 @@ export interface CanvasDeclaration {
displayName: string;
/** Short, single-sentence description shown to the agent in canvas catalogs. */
description: string;
+ /**
+ * Optional PNG path for the canvas icon. For extensions, relative paths are
+ * resolved by the runtime relative to `extension.mjs`.
+ */
+ icon?: string;
/** Optional JSON Schema for the `input` payload accepted by `canvas.open`. */
inputSchema?: CanvasJsonSchema;
/** Agent-invocable actions exposed via `invoke_canvas_action`. */
@@ -111,6 +116,8 @@ export interface CanvasOptions {
displayName: string;
/** @see CanvasDeclaration.description */
description: string;
+ /** @see CanvasDeclaration.icon */
+ icon?: string;
/** @see CanvasDeclaration.inputSchema */
inputSchema?: CanvasJsonSchema;
/**
@@ -165,6 +172,7 @@ export class Canvas {
id: options.id,
displayName: options.displayName,
description: options.description,
+ icon: options.icon,
inputSchema: options.inputSchema,
actions: wireActions,
};
diff --git a/nodejs/test/canvas.test.ts b/nodejs/test/canvas.test.ts
new file mode 100644
index 0000000000..6bb90e908a
--- /dev/null
+++ b/nodejs/test/canvas.test.ts
@@ -0,0 +1,53 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------------------------------------------*/
+
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+import { createCanvas, type CanvasDeclaration } from "../src/canvas.js";
+
+describe("createCanvas", () => {
+ it.each(["icons/counter.png", resolve("icons", "counter.png")])(
+ "preserves the icon path %s in the wire declaration",
+ (icon) => {
+ const action = { name: "increment", description: "Increment the counter" };
+ const declaration: CanvasDeclaration = {
+ id: "counter",
+ displayName: "Counter",
+ description: "Count things",
+ icon,
+ inputSchema: { type: "object" },
+ actions: [action],
+ };
+ const open = () => ({ url: "https://example.test/counter" });
+ const handler = () => ({ count: 1 });
+ const canvas = createCanvas({
+ ...declaration,
+ icon,
+ actions: [{ ...action, handler }],
+ open,
+ });
+
+ expect(canvas.declaration.icon).toBe(icon);
+ expect(JSON.parse(JSON.stringify(canvas.declaration))).toEqual(declaration);
+ expect(canvas.open).toBe(open);
+ expect(canvas.actionHandlers.get("increment")).toBe(handler);
+ }
+ );
+
+ it("omits an unspecified icon from the wire declaration", () => {
+ const canvas = createCanvas({
+ id: "counter",
+ displayName: "Counter",
+ description: "Count things",
+ open: () => ({ url: "https://example.test/counter" }),
+ });
+
+ expect(canvas.declaration.icon).toBeUndefined();
+ expect(JSON.parse(JSON.stringify(canvas.declaration))).toEqual({
+ id: "counter",
+ displayName: "Counter",
+ description: "Count things",
+ });
+ });
+});
diff --git a/nodejs/test/rust-version-snapshot.test.ts b/nodejs/test/rust-version-snapshot.test.ts
new file mode 100644
index 0000000000..13d0853401
--- /dev/null
+++ b/nodejs/test/rust-version-snapshot.test.ts
@@ -0,0 +1,58 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------------------------------------------*/
+
+import { spawnSync } from "node:child_process";
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it, onTestFinished } from "vitest";
+
+// Windows needs Git Bash's path conversion, not the WSL bash executable.
+const bash =
+ process.platform === "win32"
+ ? join(process.env.ProgramFiles ?? "C:\\Program Files", "Git", "bin", "bash.exe")
+ : "bash";
+
+describe.each(["snapshot-bundled-cli-version.sh", "snapshot-bundled-in-process-version.sh"])(
+ "%s",
+ (scriptName) => {
+ it.each(["copilot-snapshot-", "copilot sdk's snapshot-"])(
+ "reads the version under %s",
+ (prefix) => {
+ const directory = mkdtempSync(join(tmpdir(), prefix));
+ onTestFinished(() => rmSync(directory, { recursive: true, force: true }));
+ const packageFile = join(directory, "package.json");
+ writeFileSync(packageFile, JSON.stringify({ copilotCliVersion: "1.2.3" }));
+
+ const script = readFileSync(
+ new URL(`../../rust/scripts/${scriptName}`, import.meta.url),
+ "utf8"
+ );
+ const versionAssignment = script.match(/^VERSION=.*$/m)?.[0];
+ expect(versionAssignment).toBeDefined();
+
+ const result = spawnSync(
+ bash,
+ [
+ "-euc",
+ [
+ process.platform === "win32"
+ ? 'PACKAGE_FILE="$(cygpath -u "$1")"'
+ : 'PACKAGE_FILE="$1"',
+ versionAssignment,
+ 'printf "%s" "$VERSION"',
+ ].join("\n"),
+ "version-test",
+ packageFile,
+ ],
+ { encoding: "utf8", timeout: 10000 }
+ );
+
+ expect(result.error).toBeUndefined();
+ expect(result.status, result.stderr).toBe(0);
+ expect(result.stdout).toBe("1.2.3");
+ }
+ );
+ }
+);
diff --git a/python/copilot/canvas.py b/python/copilot/canvas.py
index 9b8dec5258..190cf42f1d 100644
--- a/python/copilot/canvas.py
+++ b/python/copilot/canvas.py
@@ -119,6 +119,10 @@ class CanvasDeclaration:
actions: list[CanvasAction] | None = None
"""Agent-callable actions this canvas exposes."""
+ icon: str | None = None
+ """Optional PNG icon path. For extensions, the runtime resolves relative paths
+ relative to ``extension.mjs``."""
+
def to_dict(self) -> dict[str, Any]:
result: dict[str, Any] = {
"id": self.id,
@@ -129,6 +133,8 @@ def to_dict(self) -> dict[str, Any]:
result["inputSchema"] = self.input_schema
if self.actions is not None:
result["actions"] = [action.to_dict() for action in self.actions]
+ if self.icon is not None:
+ result["icon"] = self.icon
return result
diff --git a/python/test_canvas.py b/python/test_canvas.py
index 684cef6b79..294d32330e 100644
--- a/python/test_canvas.py
+++ b/python/test_canvas.py
@@ -63,6 +63,34 @@ def test_canvas_declaration_serializes_input_schema_and_actions():
assert payload["actions"] == [action.to_dict()]
+@pytest.mark.parametrize("icon", [None, "icons/counter.png"])
+def test_canvas_declaration_serializes_optional_icon(icon):
+ decl = CanvasDeclaration(
+ id="counter",
+ display_name="Counter",
+ description="Count things",
+ icon=icon,
+ )
+ payload = decl.to_dict()
+ if icon is None:
+ assert "icon" not in payload
+ else:
+ assert payload["icon"] == icon
+
+
+def test_canvas_declaration_preserves_positional_arguments():
+ action = CanvasAction(name="increment")
+ decl = CanvasDeclaration("counter", "Counter", "Count things", {"type": "object"}, [action])
+
+ assert decl.to_dict() == {
+ "id": "counter",
+ "displayName": "Counter",
+ "description": "Count things",
+ "inputSchema": {"type": "object"},
+ "actions": [action.to_dict()],
+ }
+
+
def test_extension_info_serializes():
info = ExtensionInfo(source="github-app", name="my-ext")
assert info.to_dict() == {"source": "github-app", "name": "my-ext"}
diff --git a/rust/README.md b/rust/README.md
index 11d9637b22..5ff265f9be 100644
--- a/rust/README.md
+++ b/rust/README.md
@@ -37,6 +37,25 @@ tool name is `-`. For `available_tools` and
or the raw `mcp:-` form. For `custom_agents[].tools`
and `default_agent.excluded_tools`, use `-` directly.
+## Canvas icons
+
+Canvas authoring is experimental. Set the optional PNG path with
+`CanvasDeclaration::with_icon` or the `icon: Option` field:
+
+```rust
+use github_copilot_sdk::canvas::CanvasDeclaration;
+
+let canvas = CanvasDeclaration::new("counter", "Counter", "Count things")
+ .with_icon("icons/counter.png");
+```
+
+Supply the declaration through `SessionConfig::with_canvases` or
+`ResumeSessionConfig::with_canvases`, alongside your canvas handler. For
+extensions, the runtime resolves relative icon paths relative to
+`extension.mjs`, not the current working directory. Include the PNG with your
+extension; the SDK forwards the path unchanged. Omit the icon (the default is
+`None`) to declare a canvas without a custom icon.
+
## Architecture
```text
diff --git a/rust/scripts/snapshot-bundled-cli-version.sh b/rust/scripts/snapshot-bundled-cli-version.sh
index 0045f5e6c8..4043135b8e 100755
--- a/rust/scripts/snapshot-bundled-cli-version.sh
+++ b/rust/scripts/snapshot-bundled-cli-version.sh
@@ -25,7 +25,7 @@ if [[ ! -f "${PACKAGE_FILE}" ]]; then
exit 1
fi
-VERSION="$(node -e "console.log(require('${PACKAGE_FILE}').copilotCliVersion)")"
+VERSION="$(cd "$(dirname "${PACKAGE_FILE}")" && node -e "console.log(require('./package.json').copilotCliVersion)")"
if [[ -z "${VERSION}" ]]; then
echo "error: could not read copilotCliVersion from ${PACKAGE_FILE}" >&2
exit 1
diff --git a/rust/scripts/snapshot-bundled-in-process-version.sh b/rust/scripts/snapshot-bundled-in-process-version.sh
index 9fe2298c78..53c94021d2 100755
--- a/rust/scripts/snapshot-bundled-in-process-version.sh
+++ b/rust/scripts/snapshot-bundled-in-process-version.sh
@@ -16,7 +16,7 @@ if [[ ! -f "${PACKAGE_FILE}" ]]; then
exit 1
fi
-VERSION="$(node -e "console.log(require('${PACKAGE_FILE}').copilotCliVersion)")"
+VERSION="$(cd "$(dirname "${PACKAGE_FILE}")" && node -e "console.log(require('./package.json').copilotCliVersion)")"
if [[ -z "${VERSION}" ]]; then
echo "error: could not read copilotCliVersion from ${PACKAGE_FILE}" >&2
exit 1
diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs
index ddb92a11e6..50b1bdabf5 100644
--- a/rust/src/canvas.rs
+++ b/rust/src/canvas.rs
@@ -42,6 +42,10 @@ pub struct CanvasDeclaration {
pub display_name: String,
/// Short, single-sentence description shown to the agent in canvas catalogs.
pub description: String,
+ /// Optional PNG path for the canvas icon. For extensions, the runtime resolves
+ /// relative paths relative to `extension.mjs`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub icon: Option,
/// JSON Schema for the `input` payload accepted by `canvas.open`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_schema: Option,
@@ -61,6 +65,7 @@ impl CanvasDeclaration {
id: id.into(),
display_name: display_name.into(),
description: description.into(),
+ icon: None,
input_schema: None,
actions: None,
}
@@ -71,6 +76,13 @@ impl CanvasDeclaration {
self.description = description.into();
self
}
+
+ /// Set the optional PNG icon path. For extensions, relative paths are resolved
+ /// by the runtime relative to `extension.mjs`.
+ pub fn with_icon(mut self, icon: impl Into) -> Self {
+ self.icon = Some(icon.into());
+ self
+ }
}
/// Structured error returned from canvas handlers.
@@ -200,12 +212,57 @@ mod tests {
}
}
+ #[test]
+ fn declaration_roundtrips_icon_path() {
+ let value = json!({
+ "id": "counter",
+ "displayName": "Counter",
+ "description": "Count things",
+ "icon": "icons/counter.png",
+ });
+ let decl: CanvasDeclaration = serde_json::from_value(value.clone()).unwrap();
+
+ assert_eq!(serde_json::to_value(decl).unwrap(), value);
+ }
+
+ #[test]
+ fn declaration_builder_preserves_icon_path() {
+ let decl = CanvasDeclaration::new("counter", "Counter", "Count things")
+ .with_icon("icons/counter.png");
+
+ assert_eq!(decl.icon.as_deref(), Some("icons/counter.png"));
+ assert_eq!(
+ serde_json::to_value(decl).unwrap()["icon"],
+ "icons/counter.png"
+ );
+ }
+
+ #[test]
+ fn declaration_omits_unspecified_icon() {
+ let value = json!({
+ "id": "counter",
+ "displayName": "Counter",
+ "description": "Count things",
+ });
+ let decl: CanvasDeclaration = serde_json::from_value(value.clone()).unwrap();
+
+ assert!(decl.icon.is_none());
+ assert!(CanvasDeclaration::default().icon.is_none());
+ assert_eq!(serde_json::to_value(decl).unwrap(), value);
+ assert_eq!(
+ serde_json::to_value(CanvasDeclaration::new("counter", "Counter", "Count things"))
+ .unwrap(),
+ value,
+ );
+ }
+
#[test]
fn declaration_serializes_camel_case_and_skips_none() {
let decl = CanvasDeclaration {
id: "counter".to_string(),
display_name: "Counter".to_string(),
description: "Count things".to_string(),
+ icon: None,
input_schema: None,
actions: Some(vec![CanvasAction {
name: "increment".to_string(),