A practical guide to writing extensions using the @github/copilot-sdk extension API.
Every extension starts with the same boilerplate:
import { joinSession } from "@github/copilot-sdk/extension";
const session = await joinSession({
hooks: { /* ... */ },
tools: [ /* ... */ ],
});joinSession returns a CopilotSession object you can use to send messages and subscribe to events.
Platform notes (Windows vs macOS/Linux):
- Use
process.platform === "win32"to detect Windows at runtime.- Clipboard:
pbcopyon macOS,clipon Windows.- Use
exec()instead ofexecFile()for.cmdscripts likecode,npx,npmon Windows.- PowerShell stderr redirection uses
*>&1instead of2>&1.
Use session.log() to surface messages to the user in the CLI timeline:
const session = await joinSession({
hooks: {
onSessionStart: async () => {
await session.log("My extension loaded");
},
onPreToolUse: async (input) => {
if (input.toolName === "bash") {
await session.log(`Running: ${input.toolArgs?.command}`, { ephemeral: true });
}
},
},
tools: [],
});Levels: "info" (default), "warning", "error". Set ephemeral: true for transient messages that aren't persisted.
Tools are functions the agent can call. Define them with a name, description, JSON Schema parameters, and a handler.
tools: [
{
name: "my_tool",
description: "Does something useful",
parameters: {
type: "object",
properties: {
input: { type: "string", description: "The input value" },
},
required: ["input"],
},
handler: async (args) => {
return `Processed: ${args.input}`;
},
},
]import { execFile } from "node:child_process";
{
name: "run_command",
description: "Runs a shell command and returns its output",
parameters: {
type: "object",
properties: {
command: { type: "string", description: "The command to run" },
},
required: ["command"],
},
handler: async (args) => {
const isWindows = process.platform === "win32";
const shell = isWindows ? "powershell" : "bash";
const shellArgs = isWindows
? ["-NoProfile", "-Command", args.command]
: ["-c", args.command];
return new Promise((resolve) => {
execFile(shell, shellArgs, (err, stdout, stderr) => {
if (err) resolve(`Error: ${stderr || err.message}`);
else resolve(stdout);
});
});
},
}{
name: "fetch_data",
description: "Fetches data from an API endpoint",
parameters: {
type: "object",
properties: {
url: { type: "string", description: "The URL to fetch" },
},
required: ["url"],
},
handler: async (args) => {
const res = await fetch(args.url);
if (!res.ok) return `Error: HTTP ${res.status}`;
return await res.text();
},
}The handler receives a second argument with invocation metadata:
handler: async (args, invocation) => {
// invocation.sessionId — current session ID
// invocation.toolCallId — unique ID for this tool call
// invocation.toolName — name of the tool being called
return "done";
}Hooks intercept and modify behavior at key lifecycle points. Register them in the hooks option.
| Hook | Fires When | Can Modify |
|---|---|---|
onUserPromptSubmitted |
User sends a message | The prompt text, add context |
onPreToolUse |
Before a tool executes | Tool args, permission decision, add context |
onPostToolUse |
After a tool executes | Tool result, add context |
onSessionStart |
Session starts or resumes | Add context, modify config |
onSessionEnd |
Session ends | Cleanup actions, summary |
onErrorOccurred |
An error occurs | Error handling strategy (retry/skip/abort) |
All hook inputs include timestamp (unix ms) and cwd (working directory).
Use onUserPromptSubmitted to rewrite or augment what the user typed before the agent sees it.
hooks: {
onUserPromptSubmitted: async (input) => {
// Rewrite the prompt
return { modifiedPrompt: input.prompt.toUpperCase() };
},
}Return additionalContext to silently append instructions the agent will follow.
hooks: {
onUserPromptSubmitted: async (input) => {
return {
additionalContext: "Always respond in bullet points. Follow our team coding standards.",
};
},
}Use session.send() to programmatically inject a new user message.
hooks: {
onUserPromptSubmitted: async (input) => {
if (/\\burgent\\b/i.test(input.prompt)) {
// Fire-and-forget a follow-up message
setTimeout(() => session.send({ prompt: "Please prioritize this." }), 0);
}
},
}Tip: Guard against infinite loops if your follow-up message could re-trigger the same hook.
Use onPreToolUse to inspect and optionally deny tool execution.
hooks: {
onPreToolUse: async (input) => {
if (input.toolName === "bash") {
const cmd = String(input.toolArgs?.command || "");
if (/rm\\s+-rf/i.test(cmd) || /Remove-Item\\s+.*-Recurse/i.test(cmd)) {
return {
permissionDecision: "deny",
permissionDecisionReason: "Destructive commands are not allowed.",
};
}
}
// Allow everything else
return { permissionDecision: "allow" };
},
}hooks: {
onPreToolUse: async (input) => {
if (input.toolName === "bash") {
const redirect = process.platform === "win32" ? "*>&1" : "2>&1";
return {
modifiedArgs: {
...input.toolArgs,
command: `${input.toolArgs.command} ${redirect}`,
},
};
}
},
}Use onPostToolUse to run side effects after a tool completes.
import { exec } from "node:child_process";
hooks: {
onPostToolUse: async (input) => {
if (input.toolName === "create" || input.toolName === "edit") {
const filePath = input.toolArgs?.path;
if (filePath) {
// Open the file in VS Code
exec(`code "${filePath}"`, () => {});
}
}
},
}hooks: {
onPostToolUse: async (input) => {
if (input.toolName === "bash" && input.toolResult?.resultType === "failure") {
return {
additionalContext: "The command failed. Try a different approach.",
};
}
},
}import { exec } from "node:child_process";
hooks: {
onPostToolUse: async (input) => {
if (input.toolName === "edit") {
const filePath = input.toolArgs?.path;
if (filePath?.endsWith(".ts")) {
const result = await new Promise((resolve) => {
exec(`npx eslint "${filePath}"`, (err, stdout) => {
resolve(err ? stdout : "No lint errors.");
});
});
return { additionalContext: `Lint result: ${result}` };
}
}
},
}hooks: {
onErrorOccurred: async (input) => {
if (input.recoverable && input.errorContext === "model_call") {
return { errorHandling: "retry", retryCount: 2 };
}
return {
errorHandling: "abort",
userNotification: `An error occurred: ${input.error}`,
};
},
}hooks: {
onSessionStart: async (input) => {
// input.source is "startup", "resume", or "new"
return { additionalContext: "Remember to write tests for all changes." };
},
onSessionEnd: async (input) => {
// input.reason is "complete", "error", "abort", "timeout", or "user_exit"
},
}After calling joinSession, use session.on() to react to events in real time.
session.on("assistant.message", (event) => {
// event.data.content has the agent's response text
});session.on((event) => {
// event.type and event.data are available for all events
});session.on() returns an unsubscribe function:
const unsubscribe = session.on("tool.execution_complete", (event) => {
// event.data.toolName, event.data.success, event.data.result, event.data.error
});
// Later, stop listening
unsubscribe();Combine a hook (to detect a keyword) with a session event (to capture the response):
import { execFile } from "node:child_process";
let copyNextResponse = false;
function copyToClipboard(text) {
const cmd = process.platform === "win32" ? "clip" : "pbcopy";
const proc = execFile(cmd, [], () => {});
proc.stdin.write(text);
proc.stdin.end();
}
const session = await joinSession({
hooks: {
onUserPromptSubmitted: async (input) => {
if (/\\bcopy\\b/i.test(input.prompt)) {
copyNextResponse = true;
}
},
},
tools: [],
});
session.on("assistant.message", (event) => {
if (copyNextResponse) {
copyNextResponse = false;
copyToClipboard(event.data.content);
}
});| Event Type | Description | Key Data Fields |
|---|---|---|
assistant.message |
Agent's final response | content, messageId, toolRequests |
assistant.streaming_delta |
Token-by-token streaming (ephemeral) | totalResponseSizeBytes |
tool.execution_start |
A tool is about to run | toolCallId, toolName, arguments |
tool.execution_complete |
A tool finished running | toolCallId, toolName, success, result, error |
user.message |
User sent a message | content, attachments, source |
session.idle |
Session finished processing a turn | backgroundTasks |
session.error |
An error occurred | errorType, message, stack |
permission.requested |
Agent needs permission (shell, file write, etc.) | requestId, permissionRequest.kind |
session.shutdown |
Session is ending | shutdownType, totalPremiumRequests, codeChanges |
assistant.turn_start |
Agent begins a new thinking/response cycle | turnId |
Use session.workspacePath to locate the session's plan.md, then fs.watchFile to detect changes.
Correlate tool.execution_start / tool.execution_complete events by toolCallId to distinguish agent edits from user edits.
import { existsSync, watchFile, readFileSync } from "node:fs";
import { join } from "node:path";
import { joinSession } from "@github/copilot-sdk/extension";
const agentEdits = new Set(); // toolCallIds for in-flight agent edits
const recentAgentPaths = new Set(); // paths recently written by the agent
const session = await joinSession();
const workspace = session.workspacePath; // e.g. ~/.copilot/session-state/<id>
if (workspace) {
const planPath = join(workspace, "plan.md");
let lastContent = existsSync(planPath) ? readFileSync(planPath, "utf-8") : null;
// Track agent edits to suppress false triggers
session.on("tool.execution_start", (event) => {
if ((event.data.toolName === "edit" || event.data.toolName === "create")
&& String(event.data.arguments?.path || "").endsWith("plan.md")) {
agentEdits.add(event.data.toolCallId);
recentAgentPaths.add(planPath);
}
});
session.on("tool.execution_complete", (event) => {
if (agentEdits.delete(event.data.toolCallId)) {
setTimeout(() => {
recentAgentPaths.delete(planPath);
lastContent = existsSync(planPath) ? readFileSync(planPath, "utf-8") : null;
}, 2000);
}
});
watchFile(planPath, { interval: 1000 }, () => {
if (recentAgentPaths.has(planPath) || agentEdits.size > 0) return;
const content = existsSync(planPath) ? readFileSync(planPath, "utf-8") : null;
if (content === lastContent) return;
const wasCreated = lastContent === null && content !== null;
lastContent = content;
if (content !== null) {
session.send({
prompt: `The plan was ${wasCreated ? "created" : "edited"} by the user.`,
});
}
});
}Use fs.watch with recursive: true on process.cwd() to detect file changes.
Filter out agent edits by tracking tool.execution_start / tool.execution_complete events.
import { watch, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { joinSession } from "@github/copilot-sdk/extension";
const agentEditPaths = new Set();
const session = await joinSession();
const cwd = process.cwd();
const IGNORE = new Set(["node_modules", ".git", "dist"]);
// Track agent file edits
session.on("tool.execution_start", (event) => {
if (event.data.toolName === "edit" || event.data.toolName === "create") {
const p = String(event.data.arguments?.path || "");
if (p) agentEditPaths.add(resolve(p));
}
});
session.on("tool.execution_complete", (event) => {
// Clear after a delay to avoid race with fs.watch
const p = [...agentEditPaths].find((x) => x); // any tracked path
setTimeout(() => agentEditPaths.clear(), 3000);
});
const debounce = new Map();
watch(cwd, { recursive: true }, (eventType, filename) => {
if (!filename || eventType !== "change") return;
if (filename.split(/[\\\\\\/]/).some((p) => IGNORE.has(p))) return;
if (debounce.has(filename)) clearTimeout(debounce.get(filename));
debounce.set(filename, setTimeout(() => {
debounce.delete(filename);
const fullPath = join(cwd, filename);
if (agentEditPaths.has(resolve(fullPath))) return;
try { if (!statSync(fullPath).isFile()) return; } catch { return; }
const relPath = relative(cwd, fullPath);
session.send({
prompt: `The user edited \\`${relPath}\\`.`,
attachments: [{ type: "file", path: fullPath }],
});
}, 500));
});await session.send({ prompt: "Analyze the test results." });const response = await session.sendAndWait({ prompt: "What is 2 + 2?" });
// response?.data.content contains the agent's replyawait session.send({
prompt: "Review this file",
attachments: [
{ type: "file", path: "./src/index.ts" },
],
});const session = await joinSession({
onPermissionRequest: async (request) => {
if (request.kind === "shell") {
// request.fullCommandText has the shell command
return { kind: "approved" };
}
if (request.kind === "write") {
return { kind: "approved" };
}
return { kind: "denied-by-rules" };
},
});Register onUserInputRequest to enable the agent's ask_user tool:
const session = await joinSession({
onUserInputRequest: async (request) => {
// request.question has the agent's question
// request.choices has the options (if multiple choice)
return { answer: "yes", wasFreeform: false };
},
});An extension that combines tools, hooks, and events.
import { execFile, exec } from "node:child_process";
import { joinSession } from "@github/copilot-sdk/extension";
const isWindows = process.platform === "win32";
let copyNextResponse = false;
function copyToClipboard(text) {
const proc = execFile(isWindows ? "clip" : "pbcopy", [], () => {});
proc.stdin.write(text);
proc.stdin.end();
}
function openInEditor(filePath) {
if (isWindows) exec(`code "${filePath}"`, () => {});
else execFile("code", [filePath], () => {});
}
const session = await joinSession({
hooks: {
onUserPromptSubmitted: async (input) => {
if (/\\bcopy this\\b/i.test(input.prompt)) {
copyNextResponse = true;
}
return {
additionalContext: "Follow our team style guide. Use 4-space indentation.",
};
},
onPreToolUse: async (input) => {
if (input.toolName === "bash") {
const cmd = String(input.toolArgs?.command || "");
if (/rm\\s+-rf\\s+\\//i.test(cmd) || /Remove-Item\\s+.*-Recurse/i.test(cmd)) {
return { permissionDecision: "deny" };
}
}
},
onPostToolUse: async (input) => {
if (input.toolName === "create" || input.toolName === "edit") {
const filePath = input.toolArgs?.path;
if (filePath) openInEditor(filePath);
}
},
},
tools: [
{
name: "copy_to_clipboard",
description: "Copies text to the system clipboard.",
parameters: {
type: "object",
properties: {
text: { type: "string", description: "Text to copy" },
},
required: ["text"],
},
handler: async (args) => {
return new Promise((resolve) => {
const proc = execFile(isWindows ? "clip" : "pbcopy", [], (err) => {
if (err) resolve(`Error: ${err.message}`);
else resolve("Copied to clipboard.");
});
proc.stdin.write(args.text);
proc.stdin.end();
});
},
},
],
});
session.on("assistant.message", (event) => {
if (copyNextResponse) {
copyNextResponse = false;
copyToClipboard(event.data.content);
}
});
session.on("tool.execution_complete", (event) => {
// event.data.success, event.data.toolName, event.data.result
});