🚀 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-unstable.0
+265
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 { approveAll } from "@github/copilot-sdk";
import { joinSession } from "@github/copilot-sdk/extension";
await joinSession({
onPermissionRequest: approveAll, // Required — handle permission requests
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 { approveAll } from "@github/copilot-sdk";
import { joinSession } from "@github/copilot-sdk/extension";
const session = await joinSession({
onPermissionRequest: approveAll,
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({
onPermissionRequest: approveAll,
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({
onPermissionRequest: approveAll,
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 { approveAll } from "@github/copilot-sdk";
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({
onPermissionRequest: approveAll,
});
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 { approveAll } from "@github/copilot-sdk";
import { joinSession } from "@github/copilot-sdk/extension";
const agentEditPaths = new Set();
const session = await joinSession({
onPermissionRequest: approveAll,
});
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({
onPermissionRequest: approveAll,
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 { approveAll } from "@github/copilot-sdk";
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({
onPermissionRequest: approveAll,
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 { approveAll } from "@github/copilot-sdk";
import { joinSession } from "@github/copilot-sdk/extension";
const session = await joinSession({
onPermissionRequest: approveAll,
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[]>;

+20
-8

@@ -49,2 +49,3 @@ import { spawn } from "node:child_process";

forceStopping = false;
onListModels;
modelsCache = null;

@@ -115,2 +116,3 @@ modelsCacheLock = Promise.resolve();

}
this.onListModels = options.onListModels;
this.options = {

@@ -406,2 +408,3 @@ cliPath: options.cliPath || getBundledCliPath(),

customAgents: config.customAgents,
agent: config.agent,
configDir: config.configDir,

@@ -486,2 +489,3 @@ skillDirectories: config.skillDirectories,

customAgents: config.customAgents,
agent: config.agent,
skillDirectories: config.skillDirectories,

@@ -563,11 +567,11 @@ disabledSkills: config.disabledSkills,

*
* 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;

@@ -582,6 +586,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];

@@ -588,0 +600,0 @@ } finally {

@@ -1,2 +0,20 @@

import { CopilotClient } from "./client.js";
export declare const extension: CopilotClient;
import type { CopilotSession } from "./session.js";
import type { ResumeSessionConfig } from "./types.js";
/**
* 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 { approveAll } from "@github/copilot-sdk";
* import { joinSession } from "@github/copilot-sdk/extension";
*
* const session = await joinSession({
* onPermissionRequest: approveAll,
* tools: [myTool],
* });
* ```
*/
export declare function joinSession(config: ResumeSessionConfig): Promise<CopilotSession>;
import { CopilotClient } from "./client.js";
const extension = new CopilotClient({ isChildProcess: true });
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,
disableResume: config.disableResume ?? true
});
}
export {
extension
joinSession
};

@@ -165,2 +165,3 @@ /**

modelId: string;
reasoningEffort?: "low" | "medium" | "high" | "xhigh";
}

@@ -446,2 +447,26 @@ export interface SessionModeGetResult {

}
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;
}
/** Create typed server-scoped RPC methods (no session required). */

@@ -498,2 +523,3 @@ export declare function createServerRpc(connection: MessageConnection): {

};
log: (params: Omit<SessionLogParams, "sessionId">) => Promise<SessionLogResult>;
};

@@ -52,3 +52,4 @@ function createServerRpc(connection) {

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

@@ -55,0 +56,0 @@ }

@@ -342,2 +342,22 @@ /**

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

@@ -504,2 +504,21 @@ import { ConnectionError, ResponseError } from "vscode-jsonrpc/node";

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

@@ -506,0 +525,0 @@ export {

@@ -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[];
}

@@ -591,2 +598,8 @@ /**

/**
* 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.

@@ -609,3 +622,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"> & {
/**

@@ -612,0 +625,0 @@ * When true, skips emitting the session.resume event.

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

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

@@ -74,4 +74,5 @@ "main": "./dist/index.js",

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

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