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) => { ... },
onPostToolUseFailure: async (input, invocation) => { ... },
onSessionStart: async (input, invocation) => { ... },
onSessionEnd: async (input, invocation) => { ... },
onErrorOccurred: async (input, invocation) => { ... },
onSubagentStart: async (input, invocation) => { ... },
onSubagentStop: async (input, invocation) => { ... },
}All hook inputs include sessionId, timestamp (Date) and workingDirectory.
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, workingDirectory }
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, workingDirectory }
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, workingDirectory }
Fires only when the tool returned a successful result. To observe non-success
outcomes, register onPostToolUseFailure as well.
Output (all fields optional):
| Field | Type | Effect |
|---|---|---|
modifiedResult |
ToolResultObject |
Replaces the tool result |
additionalContext |
string |
Injected into the conversation |
Input: { toolName: string, toolArgs: unknown, error: string, timestamp, workingDirectory }
Fires after a tool execution whose result was "failure". onPostToolUse
does not fire for these outcomes, so register this handler to observe or
react to them — useful for telemetry, replay buffers, fault-injection tests,
or pairing pre/post tool tracking that would otherwise leak when the tool
fails. Note the input shape differs from onPostToolUse: only error (the
stringified failure message) is provided, not the full toolResult.
Output (all fields optional):
| Field | Type | Effect |
|---|---|---|
additionalContext |
string |
Appended as hidden guidance the model sees alongside the failed tool result |
Note: only "failure" results trigger this hook. Other non-success
resultType values ("rejected", "denied", "timeout") do not currently
fire it.
Input: { source: "startup" \| "resume" \| "new", initialPrompt?: string, timestamp, workingDirectory }
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, workingDirectory }
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, workingDirectory }
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 |
Fires before a sub-agent's first turn. The input's sessionId and the
invocation's sessionId identify the parent session, not the child.
Input: { sessionId: string, transcriptPath: string, agentName: string, agentDisplayName?: string, agentDescription?: string, timestamp, workingDirectory }
Output (optional):
| Field | Type | Effect |
|---|---|---|
additionalContext |
string |
Prepended to the child's initial prompt |
Fires after a sub-agent completes a turn. The input includes the child's last
assistant response and the parent's session metadata. agentId is available
when the task registry supplies one. This is distinct from onAgentStop, which
only runs for the top-level agent.
Input: { sessionId: string, transcriptPath: string, agentName: string, agentDisplayName?: string, agentDescription?: string, agentId?: string, agentType: string, stopReason: "end_turn", response: string, timestamp, workingDirectory }
Output (choose one, or return nothing):
| Field | Type | Effect |
|---|---|---|
decision and reason |
"block" and string |
Continue the child for another turn using reason |
modifiedResponse |
string |
Replace the child's response reported to the parent |
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.success, event.data.result
});| Event | Key Data Fields |
|---|---|
assistant.message |
content, messageId |
tool.execution_start |
toolCallId, toolName, arguments |
tool.execution_complete |
toolCallId, success, result, error |
user.message |
content, attachments, source |
session.idle |
aborted |
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.