Skip to content

Latest commit

 

History

History
679 lines (555 loc) · 20.5 KB

File metadata and controls

679 lines (555 loc) · 20.5 KB

Copilot CLI Extension Examples

A practical guide to writing extensions using the @github/copilot-sdk extension API.

Extension Skeleton

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: pbcopy on macOS, clip on Windows.
  • Use exec() instead of execFile() for .cmd scripts like code, npx, npm on Windows.
  • PowerShell stderr redirection uses *>&1 instead of 2>&1.

Logging to the Timeline

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.


Registering Custom Tools

Tools are functions the agent can call. Define them with a name, description, JSON Schema parameters, and a handler.

Basic tool

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}`;
        },
    },
];

Tool that invokes an external shell command

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);
            });
        });
    },
}

Tool that calls an external API

{
    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();
    },
}

Tool handler invocation context

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

Hooks intercept and modify behavior at key lifecycle points. Register them in the hooks option.

Available Hooks

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 successfully Tool result, add context
onPostToolUseFailure After a tool execution returns a failure Add hidden guidance to the model
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 (Date) and workingDirectory.

Modifying the user's message

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() };
    },
}

Injecting additional context into every message

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.",
        };
    },
}

Sending a follow-up message based on a keyword

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.

Blocking dangerous tool calls

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" };
    },
}

Modifying tool arguments before execution

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}`,
                },
            };
        }
    },
}

Reacting when the agent creates or edits a file

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}"`, () => {});
            }
        }
    },
}

Reacting when a tool fails

onPostToolUse only fires for successful tool executions. To observe or react to failures, register onPostToolUseFailure. The input includes input.error (the stringified failure message); only additionalContext on the return value is consumed by the runtime, and it is appended as hidden guidance alongside the failed tool result.

hooks: {
    onPostToolUseFailure: async (input) => {
        if (input.toolName === "bash") {
            return {
                additionalContext: "The command failed. Try a different approach.",
            };
        }
    },
}

Running a linter after every file edit

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}` };
            }
        }
    },
}

Handling errors with retry logic

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}`,
        };
    },
}

Session lifecycle hooks

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"
    },
}

Session Events

After calling joinSession, use session.on() to react to events in real time.

Listening to a specific event type

session.on("assistant.message", (event) => {
    // event.data.content has the agent's response text
});

Listening to all events

session.on((event) => {
    // event.type and event.data are available for all events
});

Unsubscribing from 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();

Example: Auto-copy agent responses to clipboard

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);
    }
});

Top 10 Most Useful Event Types

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

Example: Detecting when the plan file is created or edited

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.`,
            });
        }
    });
}

Example: Reacting when the user manually edits any file in the repo

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));
});

Sending Messages Programmatically

Fire-and-forget

await session.send({ prompt: "Analyze the test results." });

Send and wait for the response

const response = await session.sendAndWait({ prompt: "What is 2 + 2?" });
// response?.data.content contains the agent's reply

Send with file attachments

await session.send({
    prompt: "Review this file",
    attachments: [{ type: "file", path: "./src/index.ts" }],
});

Permission and User Input Handlers

Custom permission logic

const session = await joinSession({
    onPermissionRequest: async (request) => {
        if (request.kind === "shell") {
            // request.fullCommandText has the shell command
            return { kind: "approve-once" };
        }
        if (request.kind === "write") {
            return { kind: "approve-once" };
        }
        return { kind: "reject" };
    },
});

Handling agent questions (ask_user)

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 };
    },
});

Complete Example: Multi-Feature Extension

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
});