🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@github/copilot-sdk

Package Overview
Dependencies
Maintainers
22
Versions
72
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@github/copilot-sdk - npm Package Compare versions

Comparing version
0.1.32
to
0.1.33-preview.0
+263
docs/agent-author.md
# Agent Extension Authoring Guide
A precise, step-by-step reference for agents writing Copilot CLI extensions programmatically.
## Workflow
### Step 1: Scaffold the extension
Use the `extensions_manage` tool with `operation: "scaffold"`:
```
extensions_manage({ operation: "scaffold", name: "my-extension" })
```
This creates `.github/extensions/my-extension/extension.mjs` with a working skeleton.
For user-scoped extensions (persist across all repos), add `location: "user"`.
### Step 2: Edit the extension file
Modify the generated `extension.mjs` using `edit` or `create` tools. The file must:
- Be named `extension.mjs` (only `.mjs` is supported)
- Use ES module syntax (`import`/`export`)
- Call `joinSession({ ... })`
### Step 3: Reload extensions
```
extensions_reload({})
```
This stops all running extensions and re-discovers/re-launches them. New tools are available immediately in the same turn (mid-turn refresh).
### Step 4: Verify
```
extensions_manage({ operation: "list" })
extensions_manage({ operation: "inspect", name: "my-extension" })
```
Check that the extension loaded successfully and isn't marked as "failed".
---
## File Structure
```
.github/extensions/<name>/extension.mjs
```
Discovery rules:
- The CLI scans `.github/extensions/` relative to the git root
- It also scans the user's copilot config extensions directory
- Only immediate subdirectories are checked (not recursive)
- Each subdirectory must contain a file named `extension.mjs`
- Project extensions shadow user extensions on name collision
---
## Minimal Skeleton
```js
import { joinSession } from "@github/copilot-sdk/extension";
await joinSession({
tools: [], // Optional — custom tools
hooks: {}, // Optional — lifecycle hooks
});
```
---
## Registering Tools
```js
tools: [
{
name: "tool_name", // Required. Must be globally unique across all extensions.
description: "What it does", // Required. Shown to the agent in tool descriptions.
parameters: { // Optional. JSON Schema for the arguments.
type: "object",
properties: {
arg1: { type: "string", description: "..." },
},
required: ["arg1"],
},
handler: async (args, invocation) => {
// args: parsed arguments matching the schema
// invocation.sessionId: current session ID
// invocation.toolCallId: unique call ID
// invocation.toolName: this tool's name
//
// Return value: string or ToolResultObject
// string → treated as success
// { textResultForLlm, resultType } → structured result
// resultType: "success" | "failure" | "rejected" | "denied"
return `Result: ${args.arg1}`;
},
},
]
```
**Constraints:**
- Tool names must be unique across ALL loaded extensions. Collisions cause the second extension to fail to load.
- Handler must return a string or `{ textResultForLlm: string, resultType?: string }`.
- Handler receives `(args, invocation)` — the second argument has `sessionId`, `toolCallId`, `toolName`.
- Use `session.log()` to surface messages to the user. Don't use `console.log()` (stdout is reserved for JSON-RPC).
---
## Registering Hooks
```js
hooks: {
onUserPromptSubmitted: async (input, invocation) => { ... },
onPreToolUse: async (input, invocation) => { ... },
onPostToolUse: async (input, invocation) => { ... },
onSessionStart: async (input, invocation) => { ... },
onSessionEnd: async (input, invocation) => { ... },
onErrorOccurred: async (input, invocation) => { ... },
}
```
All hook inputs include `timestamp` (unix ms) and `cwd` (working directory).
All handlers receive `invocation: { sessionId: string }` as the second argument.
All handlers may return `void`/`undefined` (no-op) or an output object.
### onUserPromptSubmitted
**Input:** `{ prompt: string, timestamp, cwd }`
**Output (all fields optional):**
| Field | Type | Effect |
|-------|------|--------|
| `modifiedPrompt` | `string` | Replaces the user's prompt |
| `additionalContext` | `string` | Appended as hidden context the agent sees |
### onPreToolUse
**Input:** `{ toolName: string, toolArgs: unknown, timestamp, cwd }`
**Output (all fields optional):**
| Field | Type | Effect |
|-------|------|--------|
| `permissionDecision` | `"allow" \| "deny" \| "ask"` | Override the permission check |
| `permissionDecisionReason` | `string` | Shown to user if denied |
| `modifiedArgs` | `unknown` | Replaces the tool arguments |
| `additionalContext` | `string` | Injected into the conversation |
### onPostToolUse
**Input:** `{ toolName: string, toolArgs: unknown, toolResult: ToolResultObject, timestamp, cwd }`
**Output (all fields optional):**
| Field | Type | Effect |
|-------|------|--------|
| `modifiedResult` | `ToolResultObject` | Replaces the tool result |
| `additionalContext` | `string` | Injected into the conversation |
### onSessionStart
**Input:** `{ source: "startup" \| "resume" \| "new", initialPrompt?: string, timestamp, cwd }`
**Output (all fields optional):**
| Field | Type | Effect |
|-------|------|--------|
| `additionalContext` | `string` | Injected as initial context |
### onSessionEnd
**Input:** `{ reason: "complete" \| "error" \| "abort" \| "timeout" \| "user_exit", finalMessage?: string, error?: string, timestamp, cwd }`
**Output (all fields optional):**
| Field | Type | Effect |
|-------|------|--------|
| `sessionSummary` | `string` | Summary for session persistence |
| `cleanupActions` | `string[]` | Cleanup descriptions |
### onErrorOccurred
**Input:** `{ error: string, errorContext: "model_call" \| "tool_execution" \| "system" \| "user_input", recoverable: boolean, timestamp, cwd }`
**Output (all fields optional):**
| Field | Type | Effect |
|-------|------|--------|
| `errorHandling` | `"retry" \| "skip" \| "abort"` | How to handle the error |
| `retryCount` | `number` | Max retries (when errorHandling is "retry") |
| `userNotification` | `string` | Message shown to the user |
---
## Session Object
After `joinSession()`, the returned `session` provides:
### session.send(options)
Send a message programmatically:
```js
await session.send({ prompt: "Analyze the test results." });
await session.send({
prompt: "Review this file",
attachments: [{ type: "file", path: "./src/index.ts" }],
});
```
### session.sendAndWait(options, timeout?)
Send and block until the agent finishes (resolves on `session.idle`):
```js
const response = await session.sendAndWait({ prompt: "What is 2+2?" });
// response?.data.content contains the agent's reply
```
### session.log(message, options?)
Log to the CLI timeline:
```js
await session.log("Extension ready");
await session.log("Rate limit approaching", { level: "warning" });
await session.log("Connection failed", { level: "error" });
await session.log("Processing...", { ephemeral: true }); // transient, not persisted
```
### session.on(eventType, handler)
Subscribe to session events. Returns an unsubscribe function.
```js
const unsub = session.on("tool.execution_complete", (event) => {
// event.data.toolName, event.data.success, event.data.result
});
```
### Key Event Types
| Event | Key Data Fields |
|-------|----------------|
| `assistant.message` | `content`, `messageId` |
| `tool.execution_start` | `toolCallId`, `toolName`, `arguments` |
| `tool.execution_complete` | `toolCallId`, `toolName`, `success`, `result`, `error` |
| `user.message` | `content`, `attachments`, `source` |
| `session.idle` | `backgroundTasks` |
| `session.error` | `errorType`, `message`, `stack` |
| `permission.requested` | `requestId`, `permissionRequest.kind` |
| `session.shutdown` | `shutdownType`, `totalPremiumRequests` |
### session.workspacePath
Path to the session workspace directory (checkpoints, plan.md, files/). `undefined` if infinite sessions disabled.
### session.rpc
Low-level typed RPC access to all session APIs (model, mode, plan, workspace, etc.).
---
## Gotchas
- **stdout is reserved for JSON-RPC.** Don't use `console.log()` — it will corrupt the protocol. Use `session.log()` to surface messages to the user.
- **Tool name collisions are fatal.** If two extensions register the same tool name, the second extension fails to initialize.
- **Don't call `session.send()` synchronously from `onUserPromptSubmitted`.** Use `setTimeout(() => session.send(...), 0)` to avoid infinite loops.
- **Extensions are reloaded on `/clear`.** Any in-memory state is lost between sessions.
- **Only `.mjs` is supported.** TypeScript (`.ts`) is not yet supported.
- **The handler's return value is the tool result.** Returning `undefined` sends an empty success. Throwing sends a failure with the error message.
# 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:
```js
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:
```js
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
```js
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
```js
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
```js
{
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:
```js
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 | 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).
### Modifying the user's message
Use `onUserPromptSubmitted` to rewrite or augment what the user typed before the agent sees it.
```js
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.
```js
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.
```js
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.
```js
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
```js
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.
```js
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}"`, () => {});
}
}
},
}
```
### Augmenting tool results with extra context
```js
hooks: {
onPostToolUse: async (input) => {
if (input.toolName === "bash" && input.toolResult?.resultType === "failure") {
return {
additionalContext: "The command failed. Try a different approach.",
};
}
},
}
```
### Running a linter after every file edit
```js
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
```js
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
```js
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
```js
session.on("assistant.message", (event) => {
// event.data.content has the agent's response text
});
```
### Listening to all events
```js
session.on((event) => {
// event.type and event.data are available for all events
});
```
### Unsubscribing from events
`session.on()` returns an unsubscribe function:
```js
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):
```js
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.
```js
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.
```js
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
```js
await session.send({ prompt: "Analyze the test results." });
```
### Send and wait for the response
```js
const response = await session.sendAndWait({ prompt: "What is 2 + 2?" });
// response?.data.content contains the agent's reply
```
### Send with file attachments
```js
await session.send({
prompt: "Review this file",
attachments: [
{ type: "file", path: "./src/index.ts" },
],
});
```
---
## Permission and User Input Handlers
### Custom permission logic
```js
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" };
},
});
```
### Handling agent questions (ask_user)
Register `onUserInputRequest` to enable the agent's `ask_user` tool:
```js
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.
```js
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
});
```
# Copilot CLI Extensions
Extensions add custom tools, hooks, and behaviors to the Copilot CLI. They run as separate Node.js processes that communicate with the CLI over JSON-RPC via stdio.
## How Extensions Work
```
┌─────────────────────┐ JSON-RPC / stdio ┌──────────────────────┐
│ Copilot CLI │ ◄──────────────────────────────────► │ Extension Process │
│ (parent process) │ tool calls, events, hooks │ (forked child) │
│ │ │ │
│ • Discovers exts │ │ • Registers tools │
│ • Forks processes │ │ • Registers hooks │
│ • Routes tool calls │ │ • Listens to events │
│ • Manages lifecycle │ │ • Uses SDK APIs │
└─────────────────────┘ └──────────────────────┘
```
1. **Discovery**: The CLI scans `.github/extensions/` (project) and the user's copilot config extensions directory for subdirectories containing `extension.mjs`.
2. **Launch**: Each extension is forked as a child process with `@github/copilot-sdk` available via an automatic module resolver.
3. **Connection**: The extension calls `joinSession()` which establishes a JSON-RPC connection over stdio to the CLI and attaches to the user's current foreground session.
4. **Registration**: Tools and hooks declared in the session options are registered with the CLI and become available to the agent.
5. **Lifecycle**: Extensions are reloaded on `/clear` (or if the foreground session is replaced) and stopped on CLI exit (SIGTERM, then SIGKILL after 5s).
## File Structure
```
.github/extensions/
my-extension/
extension.mjs ← Entry point (required, must be .mjs)
```
- Only `.mjs` files are supported (ES modules). The file must be named `extension.mjs`.
- Each extension lives in its own subdirectory.
- The `@github/copilot-sdk` import is resolved automatically — you don't install it.
## The SDK
Extensions use `@github/copilot-sdk` for all interactions with the CLI:
```js
import { joinSession } from "@github/copilot-sdk/extension";
const session = await joinSession({
tools: [
/* ... */
],
hooks: {
/* ... */
},
});
```
The `session` object provides methods for sending messages, logging to the timeline, listening to events, and accessing the RPC API. See the `.d.ts` files in the SDK package for full type information.
## Further Reading
- `examples.md` — Practical code examples for tools, hooks, events, and complete extensions
- `agent-author.md` — Step-by-step workflow for agents authoring extensions programmatically
+38
-1
import { createServerRpc } from "./generated/rpc.js";
import { CopilotSession } from "./session.js";
import type { ConnectionState, CopilotClientOptions, GetAuthStatusResponse, GetStatusResponse, ModelInfo, ResumeSessionConfig, SessionConfig, SessionLifecycleEventType, SessionLifecycleHandler, SessionListFilter, SessionMetadata, TypedSessionLifecycleHandler } from "./types.js";
/**
* Main client for interacting with the Copilot CLI.
*
* The CopilotClient manages the connection to the Copilot CLI server and provides
* methods to create and manage conversation sessions. It can either spawn a CLI
* server process or connect to an existing server.
*
* @example
* ```typescript
* import { CopilotClient } from "@github/copilot-sdk";
*
* // Create a client with default options (spawns CLI server)
* const client = new CopilotClient();
*
* // Or connect to an existing server
* const client = new CopilotClient({ cliUrl: "localhost:3000" });
*
* // Create a session
* const session = await client.createSession({ onPermissionRequest: approveAll, model: "gpt-4" });
*
* // Send messages and handle responses
* session.on((event) => {
* if (event.type === "assistant.message") {
* console.log(event.data.content);
* }
* });
* await session.send({ prompt: "Hello!" });
*
* // Clean up
* await session.disconnect();
* await client.stop();
* ```
*/
export declare class CopilotClient {

@@ -16,2 +49,3 @@ private cliProcess;

private forceStopping;
private onListModels?;
private modelsCache;

@@ -222,6 +256,9 @@ private modelsCacheLock;

*
* If an `onListModels` handler was provided in the client options,
* it is called instead of querying the CLI server.
*
* Results are cached after the first successful call to avoid rate limiting.
* The cache is cleared when the client disconnects.
*
* @throws Error if not authenticated
* @throws Error if not connected (when no custom handler is set)
*/

@@ -228,0 +265,0 @@ listModels(): Promise<ModelInfo[]>;

+113
-73
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";

@@ -13,3 +14,3 @@ import { Socket } from "node:net";

import { getSdkProtocolVersion } from "./sdkProtocolVersion.js";
import { CopilotSession } from "./session.js";
import { CopilotSession, NO_RESULT_PERMISSION_V2_ERROR } from "./session.js";
const MIN_PROTOCOL_VERSION = 2;

@@ -50,2 +51,3 @@ function isZodSchema(value) {

forceStopping = false;
onListModels;
modelsCache = null;

@@ -116,4 +118,5 @@ modelsCacheLock = Promise.resolve();

}
this.onListModels = options.onListModels;
this.options = {
cliPath: options.cliPath || getBundledCliPath(),
cliPath: options.cliUrl ? void 0 : options.cliPath || getBundledCliPath(),
cliArgs: options.cliArgs ?? [],

@@ -384,32 +387,4 @@ cwd: options.cwd ?? process.cwd(),

}
const response = await this.connection.sendRequest("session.create", {
model: config.model,
sessionId: config.sessionId,
clientName: config.clientName,
reasoningEffort: config.reasoningEffort,
tools: config.tools?.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: toJsonSchema(tool.parameters),
overridesBuiltInTool: tool.overridesBuiltInTool
})),
systemMessage: config.systemMessage,
availableTools: config.availableTools,
excludedTools: config.excludedTools,
provider: config.provider,
requestPermission: true,
requestUserInput: !!config.onUserInputRequest,
hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)),
workingDirectory: config.workingDirectory,
streaming: config.streaming,
mcpServers: config.mcpServers,
envValueMode: "direct",
customAgents: config.customAgents,
configDir: config.configDir,
skillDirectories: config.skillDirectories,
disabledSkills: config.disabledSkills,
infiniteSessions: config.infiniteSessions
});
const { sessionId, workspacePath } = response;
const session = new CopilotSession(sessionId, this.connection, workspacePath);
const sessionId = config.sessionId ?? randomUUID();
const session = new CopilotSession(sessionId, this.connection);
session.registerTools(config.tools);

@@ -423,3 +398,42 @@ session.registerPermissionHandler(config.onPermissionRequest);

}
if (config.onEvent) {
session.on(config.onEvent);
}
this.sessions.set(sessionId, session);
try {
const response = await this.connection.sendRequest("session.create", {
model: config.model,
sessionId,
clientName: config.clientName,
reasoningEffort: config.reasoningEffort,
tools: config.tools?.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: toJsonSchema(tool.parameters),
overridesBuiltInTool: tool.overridesBuiltInTool
})),
systemMessage: config.systemMessage,
availableTools: config.availableTools,
excludedTools: config.excludedTools,
provider: config.provider,
requestPermission: true,
requestUserInput: !!config.onUserInputRequest,
hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)),
workingDirectory: config.workingDirectory,
streaming: config.streaming,
mcpServers: config.mcpServers,
envValueMode: "direct",
customAgents: config.customAgents,
agent: config.agent,
configDir: config.configDir,
skillDirectories: config.skillDirectories,
disabledSkills: config.disabledSkills,
infiniteSessions: config.infiniteSessions
});
const { workspacePath } = response;
session["_workspacePath"] = workspacePath;
} catch (e) {
this.sessions.delete(sessionId);
throw e;
}
return session;

@@ -464,33 +478,3 @@ }

}
const response = await this.connection.sendRequest("session.resume", {
sessionId,
clientName: config.clientName,
model: config.model,
reasoningEffort: config.reasoningEffort,
systemMessage: config.systemMessage,
availableTools: config.availableTools,
excludedTools: config.excludedTools,
tools: config.tools?.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: toJsonSchema(tool.parameters),
overridesBuiltInTool: tool.overridesBuiltInTool
})),
provider: config.provider,
requestPermission: true,
requestUserInput: !!config.onUserInputRequest,
hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)),
workingDirectory: config.workingDirectory,
configDir: config.configDir,
streaming: config.streaming,
mcpServers: config.mcpServers,
envValueMode: "direct",
customAgents: config.customAgents,
skillDirectories: config.skillDirectories,
disabledSkills: config.disabledSkills,
infiniteSessions: config.infiniteSessions,
disableResume: config.disableResume
});
const { sessionId: resumedSessionId, workspacePath } = response;
const session = new CopilotSession(resumedSessionId, this.connection, workspacePath);
const session = new CopilotSession(sessionId, this.connection);
session.registerTools(config.tools);

@@ -504,3 +488,43 @@ session.registerPermissionHandler(config.onPermissionRequest);

}
this.sessions.set(resumedSessionId, session);
if (config.onEvent) {
session.on(config.onEvent);
}
this.sessions.set(sessionId, session);
try {
const response = await this.connection.sendRequest("session.resume", {
sessionId,
clientName: config.clientName,
model: config.model,
reasoningEffort: config.reasoningEffort,
systemMessage: config.systemMessage,
availableTools: config.availableTools,
excludedTools: config.excludedTools,
tools: config.tools?.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: toJsonSchema(tool.parameters),
overridesBuiltInTool: tool.overridesBuiltInTool
})),
provider: config.provider,
requestPermission: true,
requestUserInput: !!config.onUserInputRequest,
hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)),
workingDirectory: config.workingDirectory,
configDir: config.configDir,
streaming: config.streaming,
mcpServers: config.mcpServers,
envValueMode: "direct",
customAgents: config.customAgents,
agent: config.agent,
skillDirectories: config.skillDirectories,
disabledSkills: config.disabledSkills,
infiniteSessions: config.infiniteSessions,
disableResume: config.disableResume
});
const { workspacePath } = response;
session["_workspacePath"] = workspacePath;
} catch (e) {
this.sessions.delete(sessionId);
throw e;
}
return session;

@@ -566,11 +590,11 @@ }

*
* If an `onListModels` handler was provided in the client options,
* it is called instead of querying the CLI server.
*
* Results are cached after the first successful call to avoid rate limiting.
* The cache is cleared when the client disconnects.
*
* @throws Error if not authenticated
* @throws Error if not connected (when no custom handler is set)
*/
async listModels() {
if (!this.connection) {
throw new Error("Client not connected");
}
await this.modelsCacheLock;

@@ -585,6 +609,14 @@ let resolveLock;

}
const result = await this.connection.sendRequest("models.list", {});
const response = result;
const models = response.models;
this.modelsCache = models;
let models;
if (this.onListModels) {
models = await this.onListModels();
} else {
if (!this.connection) {
throw new Error("Client not connected");
}
const result = await this.connection.sendRequest("models.list", {});
const response = result;
models = response.models;
}
this.modelsCache = [...models];
return [...models];

@@ -802,2 +834,7 @@ } finally {

}
if (!this.options.cliPath) {
throw new Error(
"Path to Copilot CLI is required. Please provide it via the cliPath option, or use cliUrl to rely on a remote CLI."
);
}
if (!existsSync(this.options.cliPath)) {

@@ -1134,3 +1171,6 @@ throw new Error(

return { result };
} catch (_error) {
} catch (error) {
if (error instanceof Error && error.message === NO_RESULT_PERMISSION_V2_ERROR) {
throw error;
}
return {

@@ -1137,0 +1177,0 @@ result: {

@@ -1,2 +0,19 @@

import { CopilotClient } from "./client.js";
export declare const extension: CopilotClient;
import type { CopilotSession } from "./session.js";
import type { PermissionHandler, ResumeSessionConfig } from "./types.js";
export type JoinSessionConfig = Omit<ResumeSessionConfig, "onPermissionRequest"> & {
onPermissionRequest?: PermissionHandler;
};
/**
* Joins the current foreground session.
*
* @param config - Configuration to add to the session
* @returns A promise that resolves with the joined session
*
* @example
* ```typescript
* import { joinSession } from "@github/copilot-sdk/extension";
*
* const session = await joinSession({ tools: [myTool] });
* ```
*/
export declare function joinSession(config?: JoinSessionConfig): Promise<CopilotSession>;
import { CopilotClient } from "./client.js";
const extension = new CopilotClient({ isChildProcess: true });
const defaultJoinSessionPermissionHandler = () => ({
kind: "no-result"
});
async function joinSession(config = {}) {
const sessionId = process.env.SESSION_ID;
if (!sessionId) {
throw new Error(
"joinSession() is intended for extensions running as child processes of the Copilot CLI."
);
}
const client = new CopilotClient({ isChildProcess: true });
return client.resumeSession(sessionId, {
...config,
onPermissionRequest: config.onPermissionRequest ?? defaultJoinSessionPermissionHandler,
disableResume: config.disableResume ?? true
});
}
export {
extension
joinSession
};

@@ -43,3 +43,9 @@ /**

capabilities: {
/**
* Feature flags indicating what the model supports
*/
supports: {
/**
* Whether this model supports vision/image input
*/
vision?: boolean;

@@ -51,5 +57,17 @@ /**

};
/**
* Token limits for prompts, outputs, and context window
*/
limits: {
/**
* Maximum number of prompt/input tokens
*/
max_prompt_tokens?: number;
/**
* Maximum number of output/completion tokens
*/
max_output_tokens?: number;
/**
* Maximum total context window size in tokens
*/
max_context_window_tokens: number;

@@ -62,3 +80,9 @@ };

policy?: {
/**
* Current policy state for this model
*/
state: string;
/**
* Usage terms or conditions for this model
*/
terms: string;

@@ -70,2 +94,5 @@ };

billing?: {
/**
* Billing cost multiplier relative to the base rate
*/
multiplier: number;

@@ -152,2 +179,5 @@ };

export interface SessionModelGetCurrentResult {
/**
* Currently active model identifier
*/
modelId?: string;

@@ -162,2 +192,5 @@ }

export interface SessionModelSwitchToResult {
/**
* Currently active model identifier after the switch
*/
modelId?: string;

@@ -170,3 +203,10 @@ }

sessionId: string;
/**
* Model identifier to switch to
*/
modelId: string;
/**
* Reasoning effort level to use for the model
*/
reasoningEffort?: string;
}

@@ -409,2 +449,5 @@ export interface SessionModeGetResult {

export interface SessionToolsHandlePendingToolCallResult {
/**
* Whether the tool call result was handled successfully
*/
success: boolean;

@@ -429,2 +472,5 @@ }

export interface SessionPermissionsHandlePendingPermissionRequestResult {
/**
* Whether the permission request was handled successfully
*/
success: boolean;

@@ -454,2 +500,70 @@ }

}
export interface SessionLogResult {
/**
* The unique identifier of the emitted session event
*/
eventId: string;
}
export interface SessionLogParams {
/**
* Target session identifier
*/
sessionId: string;
/**
* Human-readable message
*/
message: string;
/**
* Log severity level. Determines how the message is displayed in the timeline. Defaults to "info".
*/
level?: "info" | "warning" | "error";
/**
* When true, the message is transient and not persisted to the session event log on disk
*/
ephemeral?: boolean;
}
export interface SessionShellExecResult {
/**
* Unique identifier for tracking streamed output
*/
processId: string;
}
export interface SessionShellExecParams {
/**
* Target session identifier
*/
sessionId: string;
/**
* Shell command to execute
*/
command: string;
/**
* Working directory (defaults to session working directory)
*/
cwd?: string;
/**
* Timeout in milliseconds (default: 30000)
*/
timeout?: number;
}
export interface SessionShellKillResult {
/**
* Whether the signal was sent successfully
*/
killed: boolean;
}
export interface SessionShellKillParams {
/**
* Target session identifier
*/
sessionId: string;
/**
* Process identifier returned by shell.exec
*/
processId: string;
/**
* Signal to send (default: SIGTERM)
*/
signal?: "SIGTERM" | "SIGKILL" | "SIGINT";
}
/** Create typed server-scoped RPC methods (no session required). */

@@ -506,2 +620,7 @@ export declare function createServerRpc(connection: MessageConnection): {

};
log: (params: Omit<SessionLogParams, "sessionId">) => Promise<SessionLogResult>;
shell: {
exec: (params: Omit<SessionShellExecParams, "sessionId">) => Promise<SessionShellExecResult>;
kill: (params: Omit<SessionShellKillParams, "sessionId">) => Promise<SessionShellKillResult>;
};
};

@@ -52,2 +52,7 @@ function createServerRpc(connection) {

handlePendingPermissionRequest: async (params) => connection.sendRequest("session.permissions.handlePendingPermissionRequest", { sessionId, ...params })
},
log: async (params) => connection.sendRequest("session.log", { sessionId, ...params }),
shell: {
exec: async (params) => connection.sendRequest("session.shell.exec", { sessionId, ...params }),
kill: async (params) => connection.sendRequest("session.shell.kill", { sessionId, ...params })
}

@@ -54,0 +59,0 @@ };

@@ -5,5 +5,6 @@ /**

*/
import type { MessageConnection } from "vscode-jsonrpc/node";
import type { MessageConnection } from "vscode-jsonrpc/node.js";
import { createSessionRpc } from "./generated/rpc.js";
import type { MessageOptions, PermissionHandler, PermissionRequestResult, SessionEvent, SessionEventHandler, SessionEventType, SessionHooks, Tool, ToolHandler, TypedSessionEventHandler, UserInputHandler, UserInputResponse } from "./types.js";
export declare const NO_RESULT_PERMISSION_V2_ERROR = "Permission handlers cannot return 'no-result' when connected to a protocol v2 server.";
/** Assistant message event - the final response from the assistant. */

@@ -41,3 +42,3 @@ export type AssistantMessageEvent = Extract<SessionEvent, {

private connection;
private readonly _workspacePath?;
private _workspacePath?;
private eventHandlers;

@@ -344,2 +345,22 @@ private typedEventHandlers;

setModel(model: string): Promise<void>;
/**
* Log a message to the session timeline.
* The message appears in the session event stream and is visible to SDK consumers
* and (for non-ephemeral messages) persisted to the session event log on disk.
*
* @param message - Human-readable message text
* @param options - Optional log level and ephemeral flag
*
* @example
* ```typescript
* await session.log("Processing started");
* await session.log("Disk usage high", { level: "warning" });
* await session.log("Connection failed", { level: "error" });
* await session.log("Debug info", { ephemeral: true });
* ```
*/
log(message: string, options?: {
level?: "info" | "warning" | "error";
ephemeral?: boolean;
}): Promise<void>;
}

@@ -1,3 +0,4 @@

import { ConnectionError, ResponseError } from "vscode-jsonrpc/node";
import { ConnectionError, ResponseError } from "vscode-jsonrpc/node.js";
import { createSessionRpc } from "./generated/rpc.js";
const NO_RESULT_PERMISSION_V2_ERROR = "Permission handlers cannot return 'no-result' when connected to a protocol v2 server.";
class CopilotSession {

@@ -242,2 +243,5 @@ /**

});
if (result.kind === "no-result") {
return;
}
await this.rpc.permissions.handlePendingPermissionRequest({ requestId, result });

@@ -339,4 +343,10 @@ } catch (_error) {

});
if (result.kind === "no-result") {
throw new Error(NO_RESULT_PERMISSION_V2_ERROR);
}
return result;
} catch (_error) {
} catch (error) {
if (error instanceof Error && error.message === NO_RESULT_PERMISSION_V2_ERROR) {
throw error;
}
return { kind: "denied-no-approval-rule-and-could-not-request-from-user" };

@@ -506,5 +516,25 @@ }

}
/**
* Log a message to the session timeline.
* The message appears in the session event stream and is visible to SDK consumers
* and (for non-ephemeral messages) persisted to the session event log on disk.
*
* @param message - Human-readable message text
* @param options - Optional log level and ephemeral flag
*
* @example
* ```typescript
* await session.log("Processing started");
* await session.log("Disk usage high", { level: "warning" });
* await session.log("Connection failed", { level: "error" });
* await session.log("Debug info", { ephemeral: true });
* ```
*/
async log(message, options) {
await this.rpc.log({ message, ...options });
}
}
export {
CopilotSession
CopilotSession,
NO_RESULT_PERMISSION_V2_ERROR
};

@@ -80,2 +80,9 @@ /**

useLoggedInUser?: boolean;
/**
* Custom handler for listing available models.
* When provided, client.listModels() calls this handler instead of
* querying the CLI server. Useful in BYOK mode to return models
* available from your custom provider.
*/
onListModels?: () => Promise<ModelInfo[]> | ModelInfo[];
}

@@ -190,3 +197,5 @@ /**

import type { SessionPermissionsHandlePendingPermissionRequestParams } from "./generated/rpc.js";
export type PermissionRequestResult = SessionPermissionsHandlePendingPermissionRequestParams["result"];
export type PermissionRequestResult = SessionPermissionsHandlePendingPermissionRequestParams["result"] | {
kind: "no-result";
};
export type PermissionHandler = (request: PermissionRequest, invocation: {

@@ -592,2 +601,8 @@ sessionId: string;

/**
* Name of the custom agent to activate when the session starts.
* Must match the `name` of one of the agents in `customAgents`.
* Equivalent to calling `session.rpc.agent.select({ name })` after creation.
*/
agent?: string;
/**
* Directories to load skills from.

@@ -606,2 +621,12 @@ */

infiniteSessions?: InfiniteSessionConfig;
/**
* Optional event handler that is registered on the session before the
* session.create RPC is issued. This guarantees that early events emitted
* by the CLI during session creation (e.g. session.start) are delivered to
* the handler.
*
* Equivalent to calling `session.on(handler)` immediately after creation,
* but executes earlier in the lifecycle so no events are missed.
*/
onEvent?: SessionEventHandler;
}

@@ -611,3 +636,3 @@ /**

*/
export type ResumeSessionConfig = Pick<SessionConfig, "clientName" | "model" | "tools" | "systemMessage" | "availableTools" | "excludedTools" | "provider" | "streaming" | "reasoningEffort" | "onPermissionRequest" | "onUserInputRequest" | "hooks" | "workingDirectory" | "configDir" | "mcpServers" | "customAgents" | "skillDirectories" | "disabledSkills" | "infiniteSessions"> & {
export type ResumeSessionConfig = Pick<SessionConfig, "clientName" | "model" | "tools" | "systemMessage" | "availableTools" | "excludedTools" | "provider" | "streaming" | "reasoningEffort" | "onPermissionRequest" | "onUserInputRequest" | "hooks" | "workingDirectory" | "configDir" | "mcpServers" | "customAgents" | "agent" | "skillDirectories" | "disabledSkills" | "infiniteSessions" | "onEvent"> & {
/**

@@ -614,0 +639,0 @@ * When true, skips emitting the session.resume event.

@@ -7,3 +7,3 @@ {

},
"version": "0.1.32",
"version": "0.1.33-preview.0",
"description": "TypeScript SDK for programmatic control of GitHub Copilot CLI via JSON-RPC",

@@ -48,3 +48,3 @@ "main": "./dist/index.js",

"dependencies": {
"@github/copilot": "^1.0.2",
"@github/copilot": "^1.0.4",
"vscode-jsonrpc": "^8.2.1",

@@ -75,4 +75,5 @@ "zod": "^4.3.6"

"dist/**/*",
"docs/**/*",
"README.md"
]
}

Sorry, the diff of this file is too big to display