go — Go SDK
github.com/github/copilot-sdk/go — generics, flock, cross-platform syscalls.The Go SDK is built for cloud-native integrations: Kubernetes operators, CI runners, backend services, CLI tools. Ships as a single import path; compiles into a static binary with the CLI either externally installed or bundled and extracted on first use. Uses Go 1.18+ generics for type-safe tool handlers (DefineTool[T, U]), a hand-rolled internal/jsonrpc2 transport, and platform-split files for Windows/Unix syscalls.
Go 1.21+ Static binary Generics
📚 What is this & why does it exist?
A Go SDK exposing copilot.Client and copilot.Session types. You call client := copilot.NewClient(nil), client.Start(), defer client.Stop() — then create sessions, register tool handlers, and process events through channels and callbacks. The SDK handles subprocess spawning, JSON-RPC framing, cross-platform file locking (for bundled-CLI extraction), and typed tool argument unmarshaling via Go generics.
💡 Real-world analogy
Think of the Go SDK as a self-contained embassy kit for a static building. Every embassy (binary) ships with its own printed embassy manual (the bundled CLI), its own lockbox keys that differ per country (flock_windows vs flock_unix), and generic passport forms that get filled in with real types (DefineTool[T,U]) at compile time so you never get a "wrong form" error at the border. Because the embassy is self-contained, you can deploy it anywhere Go runs with scp and a single binary drop.
🎯 Why This Module Exists
Go dominates cloud-native tooling: Kubernetes operators, Terraform providers, CI systems, observability agents. A Go SDK unlocks embedding Copilot's agent loop into these systems without shelling out — preserving the event stream, permission flow, and tool callback ergonomics. Because Go compiles to a single static binary, a Copilot-powered operator can be deployed with kubectl apply and a pulled image, no runtime install required.
The Go SDK also showcases something the other SDKs don't: an embedded CLI pattern. Go can't trivially depend on npm packages, so internal/embeddedcli ships the CLI binary inside the SDK and extracts it on first use to a content-addressable cache directory. Concurrent processes race to extract — handled by platform-specific flock advisory locks.
🏗️ Design Decisions
| Decision | Choice Made | Alternatives | Why This Approach |
|---|---|---|---|
| JSON-RPC library | Hand-rolled internal/jsonrpc2 | sourcegraph/jsonrpc2, creachadair/jrpc2 | External libs assume HTTP/websocket; we need Content-Length-framed stdio — a different transport |
| Tool handlers | DefineTool[T, U] with generics + reflection on T | interface{} args; code generation | Generics give compile-time arg typing and let schema generation derive from the same type |
| Cross-platform syscalls | Build-tag split files: process_{windows,other}.go, flock_{unix,windows,other}.go | runtime.GOOS switches in one file | Build tags isolate platform imports; each file compiles only on its target OS |
| Concurrency primitives | sync.Mutex, sync.RWMutex, atomic.Pointer[os.Process] | Single mutex; channels-only | Fine-grained locks match access patterns: RW for start/stop (rare write, many reads); atomic for process-pointer reads during shutdown |
| CLI bundling | internal/embeddedcli + flock-guarded extraction | Require CLI install; use go:embed | flock handles parallel-process races; extraction keeps binary size normal; go:embed would 3x the compiled size |
| Public API style | Pointer receivers; NewClient constructor; nil options = defaults | Exported struct direct init | Constructor enforces invariants; lets internal fields evolve without breaking the API |
| Result unions | Hand-written rpc/result_union.go with type tags | Bare interface{}; codegen-attempted unions | Codegen can't yet emit idiomatic Go unions; hand-written keeps user code clean: if d, ok := event.Data.(*AssistantMessageData); ok {} |
Architecture
flowchart LR
subgraph pub["public (package copilot)"]
CL[Client
client.go]
SE[Session
session.go]
T[DefineTool[T,U]
definetool.go]
PH[PermissionHandler
permissions.go]
TY[Types
types.go]
SF[SessionFsProvider
session_fs_provider.go]
end
subgraph int["internal (unexported)"]
JR[jsonrpc2
custom transport]
EC[embeddedcli
extraction]
FL[flock
cross-platform]
end
subgraph gen["rpc (generated + hand-written)"]
GR[generated_rpc.go
codegen]
RU[result_union.go
hand-written]
end
CLIPROC[copilot subprocess]
CL --> JR
CL --> EC
EC --> FL
SE --> JR
CL --> GR
SE --> GR
CL --> TY
SE --> TY
SE --> T
SE --> PH
SE --> SF
GR --> RU
JR -->|stdio / TCP| CLIPROC
🚶 Narrated Walkthrough
Follow session.Send(copilot.MessageOptions{Prompt: "hi"}) through the SDK:
copilot.NewClient(nil)— returns a zero-value-safe*Client. No I/O.client.Start()— acquiresstartStopMuxas writer. IfCLIUrlis set, dials TCP. Otherwise resolves CLI path:Options.CLIPath→$PATHlookup →internal/embeddedcliextraction.- embeddedcli extraction (bundled path) — compute content hash, check cache. If missing: acquire
flockon<target>.lock, re-check, extract to.tmp, chmod 0755, atomic rename, release lock. - Spawn —
exec.CommandContext(ctx, cliPath, "serve", "--stdio"); wire stdin/stdout. - jsonrpc2.NewClient — starts reader goroutine that loops: parse Content-Length header, read N bytes, unmarshal, dispatch by kind (response/notification/request).
- Handshake —
client.RPC.GetStatus(ctx). CachenegotiatedProtocolVersion. CreateSession(ctx, cfg)— acquiresstartStopMuxreader. Serialisescfg, sendssession.createRPC. Registers callbacks intosessions[sessionId] = &Session{...}.session.On(func(evt SessionEvent){...})— appends toeventHandlersslice (under mutex).session.Send(MessageOptions{...})— sendssession.sendRPC; returnsmessageId.- Events flow from reader goroutine — notification received, unmarshal via
result_union.gotag-dispatch, invoke eacheventHandlers[i](evt). - Tool invocation (if any) — server→client request arrives; dispatcher finds session, looks up tool, runs wrapped handler
createTypedHandler(userFn)(unmarshal JSON→T, call userFn, marshalU→JSON), writes response. defer client.Stop()— acquires writer lock, sendsshutdown, cancels contexts,process.Wait(), falls back toprocess.Kill()after timeout.
Key Components
| File | Key symbols | Role (WHY) |
|---|---|---|
client.go | Client, NewClient, Start, Stop, CreateSession, RPC | Subprocess + connection + session registry. atomic.Pointer for process handle; RWMutex for lifecycle. |
session.go | Session, Send, On, Disconnect, UI | Per-conversation state, event dispatch, tool handler registry. |
types.go | All public types: ClientOptions, SessionConfig, MessageOptions, etc. | Single file avoids circular imports; 1213 lines. |
definetool.go | DefineTool[T, U], generateSchemaForType, createTypedHandler | Generic tool wrapper — schema via reflection on T, unmarshal in wrapper. |
permissions.go | Exported PermissionHandler struct (13 lines) | Namespaces pre-built handlers without polluting package root. copilot.PermissionHandler.ApproveAll. |
session_fs_provider.go | SessionFsProvider interface + adapter | Users implement an interface; adapter translates Go errors to SessionFSError results. |
telemetry.go | TraceContextProvider | W3C Trace Context plumbing. |
process_windows.go / process_other.go | Platform-specific signal handling | Build-tag split: Windows CTRL_BREAK_EVENT vs Unix SIGTERM. |
internal/jsonrpc2/jsonrpc2.go | Client, NotificationHandler, RequestHandler | Custom JSON-RPC 2.0 impl for Content-Length-framed stdio. |
internal/jsonrpc2/frame.go | ReadMessage, WriteMessage | LSP-style framing: headers then body. |
internal/embeddedcli/embeddedcli.go | Installer, EnsureInstalled | Content-addressed CLI extraction with flock guard. |
internal/flock/flock_unix.go | Lock, Unlock via unix.Flock | POSIX advisory lock. |
internal/flock/flock_windows.go | Lock, Unlock via LockFileEx | Windows kernel lock. |
internal/flock/flock_other.go | No-op fallback | For filesystems that don't support advisory locks. |
rpc/generated_rpc.go | ServerRpc, method wrappers | Codegen'd from api.schema.json. |
rpc/result_union.go | Hand-written tag-dispatch for event data | Codegen can't yet emit this idiomatically in Go. |
cmd/bundler/main.go | (executable) | Release-time tool: packages CLI into the module. Not part of user builds. |
samples/chat.go | (executable) | Chat demo. |
⚡ Algorithm Spotlight
DefineTool — generic tool wrapper
Problem. Users want to write func(args GetWeatherArgs, inv ToolInvocation) (string, error) and have the SDK generate the JSON Schema, unmarshal JSON into T, and marshal U back — without manually writing schema strings.
func DefineTool[T any, U any](name, description string, handler func(T, ToolInvocation) (U, error)) Tool {
var zero T
schema := generateSchemaForType(reflect.TypeOf(zero))
return Tool{
Name: name,
Description: description,
Parameters: schema,
Handler: createTypedHandler(handler),
}
}
The clever bit is that T is used twice — once by reflect.TypeOf(zero) to synthesise the schema, and once in the compiled closure createTypedHandler to unmarshal incoming JSON. The generic parameter keeps both in lockstep with compile-time checks.
flock-guarded CLI extraction
Content-addressed cache; double-checked locking; atomic rename. See installer.go. The POSIX advisory lock is automatically released on process exit — no stale-lock cleanup needed.
// Pseudocode from installer.go
target := filepath.Join(cacheDir, "cli-"+contentHash, "copilot")
if fileExists(target) { return target, nil }
lockFile, _ := os.OpenFile(target+".lock", os.O_CREATE|os.O_RDWR, 0644)
defer lockFile.Close()
flock.Lock(lockFile.Fd())
defer flock.Unlock(lockFile.Fd())
if fileExists(target) { return target, nil } // re-check under lock
// extract zip to .tmp, chmod, os.Rename to target
Start/Stop under RWMutex
startStopMux.Lock() for Start/Stop (writer), RLock() for all methods that need a live process. Many CreateSession calls can proceed concurrently; a Stop waits for them to finish before tearing down.
⚠ What Could Go Wrong
| Failure | Detection | Recovery | Effects |
|---|---|---|---|
| CLI binary not in PATH and not bundled | Extraction returns error | Start() returns error | Nothing created |
| Concurrent processes extracting CLI | flock loser blocks on lock | Loser re-checks after lock; sees extracted copy; uses it | One extracts; others wait briefly |
| Filesystem doesn't support flock | Runtime behaviour (NFS old versions) | flock_other.go no-op; atomic rename still prevents most corruption | Rare race possible; acceptable on exotic FS |
| CLI crashes mid-session | Reader goroutine sees io.EOF | Closes sessions with error; Client.state = Disconnected | User must call Stop() and create a new client |
| Stop called during an in-flight RPC | RWMutex blocks Stop writer until readers drain | Shutdown sent gracefully; timeout then Kill() | In-flight RPC may return context.Canceled |
| Tool handler returns error | Wrapped in ToolResult{IsError: true} | LLM sees error string | Agent can retry or apologise |
📊 Performance Profile
Zero-alloc hot path: JSON decoder reuses bufio.Reader; event dispatch iterates slice. Reflection cost: paid once per tool at DefineTool time; runtime invocations use captured closures. Goroutines: one reader per client + short-lived per-RPC; not thousands. Bundled CLI overhead: first extraction ~20-100ms (disk-bound); subsequent starts skip extraction in O(1).
Deep-Dive Highlights
JSON-RPC Transport Focus
How internal/jsonrpc2 frames messages and bridges two goroutines to the CLI.
Protocol Negotiation Focus
Version handshake in the Go SDK.
Codegen Pipeline Focus
How rpc/generated_rpc.go is produced, and why result_union.go is hand-written.
Configuration Reference
| Field | Type | Default | Purpose |
|---|---|---|---|
ClientOptions.CLIPath | string | bundled | Override binary path |
ClientOptions.CLIUrl | string | — | Connect to external TCP server |
ClientOptions.UseStdio | *bool | true | stdio vs TCP transport |
ClientOptions.AutoStart | *bool | false | Auto-Start on first use |
SessionConfig.Model | string | CLI default | Per-session model |
SessionConfig.EnableConfigDiscovery | *bool | false | Auto-load .mcp.json |
SessionConfig.OnPermissionRequest | PermissionHandlerFunc | nil (deny) | Permission gate |
SessionConfig.Tools | []Tool | nil | Custom tools |
SessionConfig.Commands | []CommandDefinition | nil | Slash commands |
SessionConfig.Hooks | *SessionHooks | nil | pre/post tool use |
SessionConfig.SystemMessage | *SystemMessageConfig | nil | replace/customize/transform |
SessionConfig.SessionFs | *SessionFsConfig | nil | Virtual FS |
SessionConfig.ReasoningEffort | string | — | low/medium/high |
Code Examples
Minimal client/session
package main
import (
"context"
"fmt"
"log"
copilot "github.com/github/copilot-sdk/go"
)
func main() {
client := copilot.NewClient(nil)
if err := client.Start(); err != nil { log.Fatal(err) }
defer client.Stop()
session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
})
if err != nil { log.Fatal(err) }
session.On(func(evt copilot.SessionEvent) {
if d, ok := evt.Data.(*copilot.AssistantMessageData); ok {
fmt.Println(d.Content)
}
})
session.Send(copilot.MessageOptions{Prompt: "Hello!"})
}
Generic DefineTool
type GetWeatherParams struct {
City string `json:"city" jsonschema:"city name"`
Unit string `json:"unit" jsonschema:"temperature unit (celsius or fahrenheit)"`
}
tool := copilot.DefineTool("get_weather", "Get weather for a city",
func(params GetWeatherParams, inv copilot.ToolInvocation) (string, error) {
return fmt.Sprintf("Weather in %s: 22°%s", params.City, params.Unit), nil
})
jsonrpc2 Request/Response types
type Request struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"` // nil for notifications
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type Response struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *Error `json:"error,omitempty"`
}
Directory Map
go/ source tree
go/
├── client.go ← Client + Start/Stop/CreateSession
├── session.go ← Session + event pump + tool dispatch
├── types.go ← All public types
├── definetool.go ← Generic DefineTool[T,U]
├── permissions.go ← Pre-built permission handlers (13 lines)
├── session_fs_provider.go ← FS interface + adapter
├── telemetry.go ← W3C Trace Context
├── sdk_protocol_version.go ← Embedded constant
├── process_windows.go ← Windows CTRL_BREAK signal
├── process_other.go ← Unix SIGTERM
├── internal/
│ ├── jsonrpc2/
│ │ ├── jsonrpc2.go ← Client, handlers
│ │ └── frame.go ← Content-Length framing
│ ├── embeddedcli/
│ │ └── embeddedcli.go ← Content-addressed extraction
│ ├── flock/
│ │ ├── flock.go ← Lock interface
│ │ ├── flock_unix.go ← POSIX advisory
│ │ ├── flock_windows.go ← LockFileEx
│ │ └── flock_other.go ← No-op fallback
│ └── e2e/ ← Conformance tests
├── embeddedcli/
│ └── installer.go ← Public installer helper
├── rpc/
│ ├── generated_rpc.go ← Codegen'd ServerRpc
│ └── result_union.go ← Hand-written event data union
├── cmd/bundler/main.go ← Release-time packaging
├── samples/chat.go ← Demo
└── go.mod