Skip to content

Commit ee39340

Browse files
Docs for extension author guide
1 parent 135ec2f commit ee39340

4 files changed

Lines changed: 1019 additions & 0 deletions

File tree

nodejs/docs/agent-author.md

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
# Agent Extension Authoring Guide
2+
3+
A precise, step-by-step reference for agents writing Copilot CLI extensions programmatically.
4+
5+
## Workflow
6+
7+
### Step 1: Scaffold the extension
8+
9+
Use the `extensions_manage` tool with `operation: "scaffold"`:
10+
11+
```
12+
extensions_manage({ operation: "scaffold", name: "my-extension" })
13+
```
14+
15+
This creates `.github/extensions/my-extension/extension.mjs` with a working skeleton.
16+
For user-scoped extensions (persist across all repos), add `location: "user"`.
17+
18+
### Step 2: Edit the extension file
19+
20+
Modify the generated `extension.mjs` using `edit` or `create` tools. The file must:
21+
- Be named `extension.mjs` (only `.mjs` is supported)
22+
- Use ES module syntax (`import`/`export`)
23+
- Call `extension.resumeSession(process.env.SESSION_ID, { ... })`
24+
- Set `disableResume: true`
25+
26+
### Step 3: Reload extensions
27+
28+
```
29+
extensions_reload({})
30+
```
31+
32+
This stops all running extensions and re-discovers/re-launches them. New tools are available immediately in the same turn (mid-turn refresh).
33+
34+
### Step 4: Verify
35+
36+
```
37+
extensions_manage({ operation: "list" })
38+
extensions_manage({ operation: "inspect", name: "my-extension" })
39+
```
40+
41+
Check that the extension loaded successfully and isn't marked as "failed".
42+
43+
---
44+
45+
## File Structure
46+
47+
```
48+
.github/extensions/<name>/extension.mjs
49+
```
50+
51+
Discovery rules:
52+
- The CLI scans `.github/extensions/` relative to the git root
53+
- It also scans the user's copilot config extensions directory
54+
- Only immediate subdirectories are checked (not recursive)
55+
- Each subdirectory must contain a file named `extension.mjs`
56+
- Project extensions shadow user extensions on name collision
57+
58+
---
59+
60+
## Minimal Skeleton
61+
62+
```js
63+
import { approveAll } from "@github/copilot-sdk";
64+
import { extension } from "@github/copilot-sdk/extension";
65+
66+
await extension.resumeSession(process.env.SESSION_ID, {
67+
disableResume: true, // Required — extensions attach to existing sessions
68+
onPermissionRequest: approveAll, // Required — handle permission requests
69+
tools: [], // Optional — custom tools
70+
hooks: {}, // Optional — lifecycle hooks
71+
});
72+
```
73+
74+
---
75+
76+
## Registering Tools
77+
78+
```js
79+
tools: [
80+
{
81+
name: "tool_name", // Required. Must be globally unique across all extensions.
82+
description: "What it does", // Required. Shown to the agent in tool descriptions.
83+
parameters: { // Optional. JSON Schema for the arguments.
84+
type: "object",
85+
properties: {
86+
arg1: { type: "string", description: "..." },
87+
},
88+
required: ["arg1"],
89+
},
90+
handler: async (args, invocation) => {
91+
// args: parsed arguments matching the schema
92+
// invocation.sessionId: current session ID
93+
// invocation.toolCallId: unique call ID
94+
// invocation.toolName: this tool's name
95+
//
96+
// Return value: string or ToolResultObject
97+
// string → treated as success
98+
// { textResultForLlm, resultType } → structured result
99+
// resultType: "success" | "failure" | "rejected" | "denied"
100+
return `Result: ${args.arg1}`;
101+
},
102+
},
103+
]
104+
```
105+
106+
**Constraints:**
107+
- Tool names must be unique across ALL loaded extensions. Collisions cause the second extension to fail to load.
108+
- Handler must return a string or `{ textResultForLlm: string, resultType?: string }`.
109+
- Handler receives `(args, invocation)` — the second argument has `sessionId`, `toolCallId`, `toolName`.
110+
- Use `console.error()` for debug logging (stdout is reserved for JSON-RPC).
111+
112+
---
113+
114+
## Registering Hooks
115+
116+
```js
117+
hooks: {
118+
onUserPromptSubmitted: async (input, invocation) => { ... },
119+
onPreToolUse: async (input, invocation) => { ... },
120+
onPostToolUse: async (input, invocation) => { ... },
121+
onSessionStart: async (input, invocation) => { ... },
122+
onSessionEnd: async (input, invocation) => { ... },
123+
onErrorOccurred: async (input, invocation) => { ... },
124+
}
125+
```
126+
127+
All hook inputs include `timestamp` (unix ms) and `cwd` (working directory).
128+
All handlers receive `invocation: { sessionId: string }` as the second argument.
129+
All handlers may return `void`/`undefined` (no-op) or an output object.
130+
131+
### onUserPromptSubmitted
132+
133+
**Input:** `{ prompt: string, timestamp, cwd }`
134+
135+
**Output (all fields optional):**
136+
| Field | Type | Effect |
137+
|-------|------|--------|
138+
| `modifiedPrompt` | `string` | Replaces the user's prompt |
139+
| `additionalContext` | `string` | Appended as hidden context the agent sees |
140+
141+
### onPreToolUse
142+
143+
**Input:** `{ toolName: string, toolArgs: unknown, timestamp, cwd }`
144+
145+
**Output (all fields optional):**
146+
| Field | Type | Effect |
147+
|-------|------|--------|
148+
| `permissionDecision` | `"allow" \| "deny" \| "ask"` | Override the permission check |
149+
| `permissionDecisionReason` | `string` | Shown to user if denied |
150+
| `modifiedArgs` | `unknown` | Replaces the tool arguments |
151+
| `additionalContext` | `string` | Injected into the conversation |
152+
153+
### onPostToolUse
154+
155+
**Input:** `{ toolName: string, toolArgs: unknown, toolResult: ToolResultObject, timestamp, cwd }`
156+
157+
**Output (all fields optional):**
158+
| Field | Type | Effect |
159+
|-------|------|--------|
160+
| `modifiedResult` | `ToolResultObject` | Replaces the tool result |
161+
| `additionalContext` | `string` | Injected into the conversation |
162+
163+
### onSessionStart
164+
165+
**Input:** `{ source: "startup" \| "resume" \| "new", initialPrompt?: string, timestamp, cwd }`
166+
167+
**Output (all fields optional):**
168+
| Field | Type | Effect |
169+
|-------|------|--------|
170+
| `additionalContext` | `string` | Injected as initial context |
171+
172+
### onSessionEnd
173+
174+
**Input:** `{ reason: "complete" \| "error" \| "abort" \| "timeout" \| "user_exit", finalMessage?: string, error?: string, timestamp, cwd }`
175+
176+
**Output (all fields optional):**
177+
| Field | Type | Effect |
178+
|-------|------|--------|
179+
| `sessionSummary` | `string` | Summary for session persistence |
180+
| `cleanupActions` | `string[]` | Cleanup descriptions |
181+
182+
### onErrorOccurred
183+
184+
**Input:** `{ error: string, errorContext: "model_call" \| "tool_execution" \| "system" \| "user_input", recoverable: boolean, timestamp, cwd }`
185+
186+
**Output (all fields optional):**
187+
| Field | Type | Effect |
188+
|-------|------|--------|
189+
| `errorHandling` | `"retry" \| "skip" \| "abort"` | How to handle the error |
190+
| `retryCount` | `number` | Max retries (when errorHandling is "retry") |
191+
| `userNotification` | `string` | Message shown to the user |
192+
193+
---
194+
195+
## Session Object
196+
197+
After `resumeSession()`, the returned `session` provides:
198+
199+
### session.send(options)
200+
201+
Send a message programmatically:
202+
```js
203+
await session.send({ prompt: "Analyze the test results." });
204+
await session.send({
205+
prompt: "Review this file",
206+
attachments: [{ type: "file", path: "./src/index.ts" }],
207+
});
208+
```
209+
210+
### session.sendAndWait(options, timeout?)
211+
212+
Send and block until the agent finishes (resolves on `session.idle`):
213+
```js
214+
const response = await session.sendAndWait({ prompt: "What is 2+2?" });
215+
console.error(response?.data.content);
216+
```
217+
218+
### session.log(message, options?)
219+
220+
Log to the CLI timeline:
221+
```js
222+
await session.log("Extension ready");
223+
await session.log("Rate limit approaching", { level: "warning" });
224+
await session.log("Connection failed", { level: "error" });
225+
await session.log("Processing...", { ephemeral: true }); // transient, not persisted
226+
```
227+
228+
### session.on(eventType, handler)
229+
230+
Subscribe to session events. Returns an unsubscribe function.
231+
```js
232+
const unsub = session.on("tool.execution_complete", (event) => {
233+
console.error(`Tool ${event.data.toolName}: ${event.data.success}`);
234+
});
235+
```
236+
237+
### Key Event Types
238+
239+
| Event | Key Data Fields |
240+
|-------|----------------|
241+
| `assistant.message` | `content`, `messageId` |
242+
| `tool.execution_start` | `toolCallId`, `toolName`, `arguments` |
243+
| `tool.execution_complete` | `toolCallId`, `toolName`, `success`, `result`, `error` |
244+
| `user.message` | `content`, `attachments`, `source` |
245+
| `session.idle` | `backgroundTasks` |
246+
| `session.error` | `errorType`, `message`, `stack` |
247+
| `permission.requested` | `requestId`, `permissionRequest.kind` |
248+
| `session.shutdown` | `shutdownType`, `totalPremiumRequests` |
249+
250+
### session.workspacePath
251+
252+
Path to the session workspace directory (checkpoints, plan.md, files/). `undefined` if infinite sessions disabled.
253+
254+
### session.rpc
255+
256+
Low-level typed RPC access to all session APIs (model, mode, plan, workspace, etc.).
257+
258+
---
259+
260+
## Gotchas
261+
262+
1. **stdout is reserved for JSON-RPC.** Use `console.error()` for debug output. `console.log()` will corrupt the protocol.
263+
2. **Tool name collisions are fatal.** If two extensions register the same tool name, the second extension fails to initialize.
264+
3. **`disableResume: true` is required.** Extensions always attach to existing sessions.
265+
4. **Don't call `session.send()` synchronously from `onUserPromptSubmitted`.** Use `setTimeout(() => session.send(...), 0)` to avoid infinite loops.
266+
5. **Extensions are reloaded on `/clear`.** Any in-memory state is lost between sessions.
267+
6. **Only `.mjs` is supported.** TypeScript (`.ts`) is not yet supported.
268+
7. **The handler's return value is the tool result.** Returning `undefined` sends an empty success. Throwing sends a failure with the error message.

0 commit comments

Comments
 (0)