A precise, step-by-step reference for agents writing Copilot CLI extensions programmatically.
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".
Modify the generated extension.mjs using edit or create tools. The file must:
- Be named
extension.mjs(only.mjsis supported) - Use ES module syntax (
import/export) - Call
joinSession({ ... })
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).
extensions_manage({ operation: "list" })
extensions_manage({ operation: "inspect", name: "my-extension" })
Check that the extension loaded successfully and isn't marked as "failed".
.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
import { joinSession } from "@github/copilot-sdk/extension";
await joinSession({
tools: [], // Optional — custom tools
hooks: {}, // Optional — lifecycle hooks
});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 hassessionId,toolCallId,toolName. - Use
session.log()to surface messages to the user. Don't useconsole.log()(stdout is reserved for JSON-RPC).
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.
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 |
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 |
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 |
Input: { source: "startup" \| "resume" \| "new", initialPrompt?: string, timestamp, cwd }
Output (all fields optional):
| Field | Type | Effect |
|---|---|---|
additionalContext |
string |
Injected as initial context |
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 |
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 |
After joinSession(), the returned session provides:
Send a message programmatically:
await session.send({ prompt: "Analyze the test results." });
await session.send({
prompt: "Review this file",
attachments: [{ type: "file", path: "./src/index.ts" }],
});Send and block until the agent finishes (resolves on session.idle):
const response = await session.sendAndWait({ prompt: "What is 2+2?" });
// response?.data.content contains the agent's replyLog to the CLI timeline:
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 persistedSubscribe to session events. Returns an unsubscribe function.
const unsub = session.on("tool.execution_complete", (event) => {
// event.data.toolName, event.data.success, event.data.result
});| 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 |
Path to the session workspace directory (checkpoints, plan.md, files/). undefined if infinite sessions disabled.
Low-level typed RPC access to all session APIs (model, mode, plan, workspace, etc.).
- stdout is reserved for JSON-RPC. Don't use
console.log()— it will corrupt the protocol. Usesession.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 fromonUserPromptSubmitted. UsesetTimeout(() => session.send(...), 0)to avoid infinite loops. - Extensions are reloaded on
/clear. Any in-memory state is lost between sessions. - Only
.mjsis supported. TypeScript (.ts) is not yet supported. - The handler's return value is the tool result. Returning
undefinedsends an empty success. Throwing sends a failure with the error message.