| name | github-copilot-sdk |
| description | Authoritative reference for working on MultiTable's GitHub Copilot SDK integration (`@github/copilot-sdk`). Trigger when the user mentions the Copilot SDK, `CopilotClient`, `CopilotSession`, `session.send`, `sendAndWait`, `session.idle`, `assistant.message_delta`, `onPermissionRequest`, `onUserInputRequest`, `onElicitationRequest`, plan / autopilot / chat mode for Copilot, session.abort, `~/.copilot/session-state`, or adding/modifying a Copilot provider under `packages/daemon/src/agent/providers/`. |
| allowed-tools | Read, Grep, Glob, Bash, Edit, Write |
GitHub Copilot SDK reference for MultiTable
Pinned SDK package: @github/copilot-sdk (Node ≥ 20.0.0 per nodejs/package.json engines; the README says 18+ but the engines field is the real floor). Repo: https://github.com/github/copilot-sdk, TypeScript source under nodejs/. The SDK is in public preview. Type signatures and behavior in this skill are quoted from the upstream nodejs/src/* source — re-verify against the locally installed node_modules/@github/copilot-sdk/dist/*.d.ts after any version bump.
This skill is strictly Copilot-only. Do not import Claude Agent SDK names (query, canUseTool, permissionMode: 'plan', Query.interrupt, forkSession) or Codex SDK names (Thread, runStreamed, approvalPolicy, sandboxMode, additionalDirectories) into Copilot code or reasoning. Each SDK has its own primitives — see multitable/claude-codex-comparison.md for the side-by-side.
The two facts that shape everything
-
The SDK is a JSON-RPC client driving a long-lived copilot CLI child process. new CopilotClient() + client.start() spawns the bundled CLI in server mode and opens a vscode-jsonrpc MessageConnection over stdio (or TCP). Multiple CopilotSessions multiplex inside that one child. This is architecturally different from Codex (per-turn codex exec spawn) and Claude (in-process query() async-iterable). One client = one CLI child = many sessions.
-
session.send() returns immediately with a messageId. All progress is push-only via session.on(eventType, handler). There is no AsyncIterable, no Web ReadableStream, no Node EventEmitter. Subscribing returns an unsubscribe function — there is no off(). Drop the unsubscribe and you've leaked a handler for the session's lifetime.
Quick task → file map
Decision tree: which lever to pull?
Need to BLOCK a tool call?
├── Always block, no UI? ─── hooks.onPreToolUse → { permissionDecision: 'deny' }
├── Allow without prompting? ─── defineTool({ ..., skipPermission: true }) OR onPreToolUse → 'allow'
├── Ask the user every time? ─── onPreToolUse → { permissionDecision: 'ask' } → falls through to onPermissionRequest
└── Approve everything (dev)? ─── onPermissionRequest: approveAll (built-in)
Need the agent to ASK the user something?
├── Free-text / multiple choice? ─── onUserInputRequest (UserInputRequest)
├── Structured form / URL? ─── onElicitationRequest (ElicitationContext)
└── Permission for a side effect? ─── onPermissionRequest (PermissionRequest)
ALL THREE are mandatory to wire (or the agent hangs).
Need to RUN code on a lifecycle event?
└── SessionConfig.hooks.onSessionStart / onUserPromptSubmitted /
onPreToolUse / onPostToolUse / onSessionEnd / onErrorOccurred
Need to STOP a turn mid-stream?
└── await session.abort() (separate method; NO AbortSignal on send())
The session is still usable — call send() again.
Need to KILL the session entirely?
└── await session.disconnect() (RPC session.destroy; the object is dead)
Need to RESUME a prior conversation?
└── await client.resumeSession(sessionId, config)
REQUIRES the same sessionId you supplied on createSession.
BYOK keys must be re-supplied; not persisted.
Need PLAN mode / CHAT mode / AUTO mode?
└── Not first-class SDK fields. The TUI's "Plan Mode" / "Autopilot" are CLI-only.
Approximate via permission policy + system prompt.
See reference/modes-and-permissions.md for the recipe.
Five rules that get violated most
-
session.idle — and ONLY session.idle — means the agent loop is fully done. assistant.turn_end is the end of one LLM call; the agent loop typically chains many turns (tool → model → tool → model → final). Do not unlock the composer or send the next user message on assistant.turn_end. This was bug #1 in Claude/Codex too — the symptom in Copilot is "user message disappears" because it was steered into a turn that wasn't actually finished.
-
Streaming deltas are ADDITIVE, not cumulative. assistant.message_delta.deltaContent is the chunk to append to your buffer. This is the opposite of Codex (item.updated.item.text is the cumulative body). Mixing the two strategies corrupts the live preview. Always replace your live buffer with assistant.message.content when the canonical message arrives, to absorb any whitespace drift.
-
There are THREE separate "ask the user" channels — onPermissionRequest, onUserInputRequest, onElicitationRequest. Each is a distinct callback on SessionConfig. The agent blocks indefinitely waiting on whichever one you forgot to wire (no host-side timeout). onPermissionRequest is the only one that's mandatory at construction — but if you skip the other two, the agent silently hangs the first time it tries to use them. Wire all three for production.
-
onPermissionRequest is mandatory. Omitting it doesn't disable permission gating — it crashes when the agent first tries to use a tool. Use the exported approveAll for daemon/headless use, or thread it through MultiTable's PermissionManager.
-
Abort is a method, not a signal. send() does not accept an AbortSignal. Cancel via await session.abort(), which fires agent.abort and still fires session.idle afterwards. The session remains valid — you can send() again on the same instance.
Three things to NOT confuse with each other
session.ui.{confirm,select,input,elicitation} is host → agent (you push a UI request into the agent's context).
onUserInputRequest / onElicitationRequest is agent → host (the agent asks you something).
onPermissionRequest is also agent → host but for tool gating specifically (shell, write, read, mcp, url, custom-tool, memory, hook).
Mixing them up is a recipe for a hung session and a confused chat UI.
Ground-truth files
When in doubt about behavior, these are authoritative (in this order):
- The locally installed types, once we install the SDK:
node_modules/@github/copilot-sdk/dist/index.d.ts (and dist/types.d.ts, dist/session.d.ts).
- Generated event taxonomy (currently upstream-only):
nodejs/src/generated/session-events.ts — ~60 typed event variants.
- Generated RPC method list (upstream):
nodejs/src/generated/rpc.ts.
- Upstream sources when the local types are vague:
nodejs/src/client.ts, nodejs/src/session.ts, nodejs/src/types.ts. Use gh api -H "Accept: application/vnd.github.raw" repos/github/copilot-sdk/contents/nodejs/src/<file> for raw reads — GitHub's blob view truncates files >1000 lines.
- Official docs:
https://github.com/github/copilot-sdk/tree/main/docs and https://docs.github.com/en/copilot/how-tos/copilot-sdk/sdk-getting-started.
- Public-preview blog post (concept overview, not API surface):
https://github.blog/news-insights/company-news/build-an-agent-into-any-app-with-the-github-copilot-sdk/.
Where this SDK will live in our codebase
Today: there is no Copilot integration. The AddAgentModal still has GitHub Copilot as comingSoon: true (packages/web/src/components/modals/AddAgentModal.tsx:24). When we wire it up, follow the existing pattern:
packages/daemon/src/
├── agent/
│ ├── manager.ts ← add a 'copilot' branch in sendTurn dispatch
│ ├── providers/
│ │ ├── copilot.ts ← NEW: the Copilot adapter; will own a singleton
│ │ │ CopilotClient + per-session CopilotSession cache
│ │ ├── codex.ts ← unchanged
│ │ ├── types.ts ← extend ProviderAdapter.name to include 'copilot'
│ │ └── index.ts
│ └── types.ts ← AgentSession.provider: 'claude' | 'codex' | 'copilot'
└── transcripts/
└── copilotParser.ts ← NEW: parse ~/.copilot/session-state/<id>/checkpoints/*.json → Message[]
See multitable/integration-plan.md for the step-by-step. The big architectural difference vs. Codex: Copilot has a long-lived child shared across sessions, so the adapter must own the client lifecycle (one start() on first use, stop() on daemon shutdown), not start fresh per turn.