claudego

package module
v0.0.0-...-c6d30d7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 28, 2026 License: MIT Imports: 19 Imported by: 0

README

claude-go

A Go library and CLI for driving Claude Code as a long-lived headless subprocess over bidirectional stream-json, using a Claude subscription.

Every existing driver for this surface is Node or TypeScript — Anthropic's Agent SDK, both ACP adapters, T3 Code. This is the Go one.

Why a subprocess and not an API client

Extracting subscription OAuth and calling the Anthropic API directly has been blocked server-side since January 2026 and enforced against the projects that tried. Spawning the official claude binary with the user's own login is the path that survived. claude-go does not touch credentials at all — it spawns claude and lets it authenticate.

Status

Built and verified against claude 2.1.226. The full surface works: multi-turn sessions over bidirectional stream-json, in-process tool hosting on the control channel, permission callbacks, per-turn and cumulative usage, a control-channel interrupt, wire capture (Options.CaptureDir records each API request the claude child sends), image passthrough (user and tool-result images ride the session and bridge surfaces to the model), the stdio bridge mode for non-Go hosts, and a Pi-extension consumer of that bridge. The design thesis held under measurement: the Pipe profile's context floor is 877 input tokens (vs ~20k with Claude Code's default scaffolding), measured 2026-08-10 in the deliberately-run paid conformance tier. DESIGN.md is the doctrine home.

Build and verify

scripts/verify.sh          # the definition of green — fmt, vet, build, test, fuzz, binary, conformance
scripts/verify.sh test     # a single gate
scripts/verify.sh live     # token-free checks against a real installed claude (not in CI)
go build -o claude-go ./cmd/claude-go

Requires Go (version pinned in go.mod), python3 (stdlib-only harness), and node ≥ 22.6 for the Pi-extension smoke. The default battery spends no paid tokens and touches no real claude; the separate live and paid tiers are deliberate, never ridden by CI.

Layout

path contents
streamjson/ NDJSON transport framing — the only frozen wire surface
cmd/claude-go/ the CLI: conformance/debug driver and stdio bridge mode for non-Go hosts
adapters/pi/ thin TypeScript extension registering the bridge as a Pi custom provider
contracts/ frozen language-neutral descriptions of the child-process wire surface
tests/ stdlib-only Python conformance harness, driving the real binary
fixtures/ golden files
testdata/fuzz-seeds/ committed fuzz corpus, seeded from real claude v2.1.226 output

License

MIT — see LICENSE.

Documentation

Overview

Package claudego drives Claude Code as a long-lived headless subprocess over bidirectional stream-json, on a Claude subscription.

The thesis: a Go program importing only this package holds a multi-turn session in which the model's only tools are functions defined in that program. The library spawns exactly one `claude` child per session and nothing else, hosts the caller's tools in-process over the control channel, asserts the granted tool surface at startup, and returns structured usage per turn. It never reads, forwards, or persists credentials — the spawned `claude` authenticates itself.

DESIGN.md (ratified 2026-08-09) is the doctrine home; the wire facts the library depends on are frozen under contracts/, pinned to the characterized `claude` release.

Example (AcceptanceShape)

Example_acceptanceShape is the thesis as a runnable program: a caller importing only this library holds a multi-turn session in which the model's only tools are functions defined right here — the tool surface asserted at startup (I6), usage returned structurally per turn.

In the default battery it runs against the scripted fake claude (fixtures characterized from the real 2.1.226); the paid tier reruns the same shape against the real binary.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	claudego "github.com/Middlewatch/claude-go"
)

func main() {
	adder := claudego.Tool{
		Name:        "add",
		Description: "Add two integers and return their sum.",
		InputSchema: json.RawMessage(`{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}`),
		Handler: func(ctx context.Context, input json.RawMessage) (*claudego.ToolResult, error) {
			var args struct{ A, B float64 }
			if err := json.Unmarshal(input, &args); err != nil {
				return nil, err // becomes an error-flagged result, not a session failure
			}
			return claudego.TextResult(fmt.Sprint(args.A + args.B)), nil
		},
	}

	opts := claudego.Pipe("You are a terse calculator.", adder)
	opts.ClaudePath = "tests/fake_claude.py" // the paid tier drops this line
	opts.ToolServerName = "calc"

	ctx := context.Background()
	sess, err := claudego.Open(ctx, opts)
	if err != nil {
		log.Fatal(err)
	}
	defer sess.Close()

	for _, prompt := range []string{"What is 2 plus 3?", "And 40 plus 2?"} {
		res, err := sess.Prompt(ctx, prompt)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Printf("turn: %q (tools granted: %v)\n", res.ResultText, sess.Init().Tools)
	}
}
Output:
turn: "5" (tools granted: [mcp__calc__add])
turn: "5" (tools granted: [mcp__calc__add])

Index

Examples

Constants

This section is empty.

Variables

View Source
var EffortLevels = []string{"low", "medium", "high", "xhigh", "max"}

EffortLevels are the levels the pinned CLI accepts for --effort (`claude --help`, 2.1.226). Exported so a host can build a picker from the same list this package validates against.

View Source
var ErrAPIKeyAuth = errors.New("claudego: subscription auth intended but init reports an API key source")

ErrAPIKeyAuth reports that init named an API-key source. This library is a subscription lane: it fails loudly rather than silently billing a key. It also surfaces from the first turn, with the init snapshot.

View Source
var ErrSessionClosed = errors.New("claudego: session closed")

ErrSessionClosed reports an operation on a session after Close.

View Source
var ErrToolSurfaceMismatch = errors.New("claudego: system/init tools do not match the requested set")

ErrToolSurfaceMismatch reports that system/init advertised a tool set different from the requested one (I6). It surfaces from the first turn's Events/Prompt, since the wire only emits init after the first user message.

View Source
var ErrUnknownEffort = errors.New("claudego: unknown effort level")

ErrUnknownEffort reports an Options.Effort the pinned CLI does not accept. Checked before the spawn so the caller gets the valid set rather than a child exiting on a usage error.

Functions

This section is empty.

Types

type AssistantEvent

type AssistantEvent struct {
	Message         json.RawMessage
	ParentToolUseID string
	// contains filtered or unexported fields
}

AssistantEvent carries an API-shape assistant message object, retained as raw bytes: message content is schema-fluid and callers that need blocks unmarshal what they use (I4).

func (*AssistantEvent) Raw

func (e *AssistantEvent) Raw() []byte

type Boundary

type Boundary int

Boundary classifies message/content-block edges in the stream.

const (
	BoundaryNone Boundary = iota
	MessageStart
	MessageStop
	BlockStart
	BlockStop
)

type ContextUsage

type ContextUsage struct {
	Used int64
	Max  int64
	Raw  json.RawMessage // the full response, unknown fields retained (I4)
}

ContextUsage is the CLI's context-window accounting.

type CumulativeUsage

type CumulativeUsage struct {
	ByModel map[string]Usage
	// TotalCostUSD is the CLI's client-side estimate (I7): useful for
	// telemetry, not an invoice.
	TotalCostUSD float64
	Turns        int
}

CumulativeUsage is the session's running total, read from the LATEST result event's modelUsage / total_cost_usd / num_turns — the wire's running totals, never summed across results (the falsification witness is TestCumulativeIsLatestNotSum). Per-model accounting is the source of truth (I7).

type DeltaKind

type DeltaKind int

DeltaKind classifies the incremental text a StreamEvent carries.

const (
	DeltaNone DeltaKind = iota
	DeltaText
	DeltaThinking
)

type Event

type Event interface {
	// Raw returns the full frame bytes as read from the wire.
	Raw() []byte
	// contains filtered or unexported methods
}

Event is one decoded frame from the claude child's stdout stream. The set of concrete types is sealed; consume it with a type switch. Every event retains its full frame bytes (I4), so nothing the wire said is ever lost to decoding.

type InitEvent

type InitEvent struct {
	Model        string
	Tools        []string
	Capabilities []string // I5: the feature-detection surface
	APIKeySource string
	SessionID    string
	MCPServers   []MCPServerStatus
	// contains filtered or unexported fields
}

InitEvent is the system/init snapshot. Characterized timing (contracts/events.md): it arrives after every user message frame and never before the first one.

func (*InitEvent) Raw

func (e *InitEvent) Raw() []byte

type MCPServerStatus

type MCPServerStatus struct {
	Name   string `json:"name"`
	Status string `json:"status"`
}

MCPServerStatus is one entry of system/init's mcp_servers array.

type Options

type Options struct {
	ClaudePath string   // claude binary; "" = "claude" from PATH
	Dir        string   // child working directory; "" inherits
	Env        []string // extra KEY=VALUE appended to the parent env (I1: never credentials we read)
	// CaptureDir, when set, records every HTTP request the child sends to
	// the model API as one JSON file in that directory (credential headers
	// redacted). Open points the child at a loopback proxy through
	// ANTHROPIC_BASE_URL, forwarding to whatever base URL the environment
	// already had; Close stops it. The wire is the only place the CLI's own
	// system-prompt additions and per-turn reminders are visible.
	CaptureDir         string
	Model              string // --model; "" = CLI default
	SystemPrompt       string // delivered via the initialize control request
	AppendSystemPrompt string

	// BuiltinTools and SettingSources are tri-state (contracts/spawn-args.md):
	// nil omits the flag entirely (CLI default set); an empty slice sends
	// the empty form (no builtins / no setting sources).
	BuiltinTools   []string
	SettingSources []string

	// PermissionMode is passed through as --permission-mode. Characterized
	// caution (2.1.226 capture): "dontAsk" DENIES tool calls without asking; a session
	// whose tools must run wants "bypassPermissions" or an OnCanUseTool
	// callback.
	PermissionMode string // "", "default", "acceptEdits", "dontAsk", "plan", "bypassPermissions"

	// Effort is the CLI's reasoning effort for the session (--effort).
	// "" leaves the CLI's own default. Validated at Open: an unknown
	// level is a caller bug worth catching before a spawn, not a child
	// that exits with a usage error. Session-scoped by the CLI's own
	// definition ("effort level for the current session"), so changing
	// it means opening a new session.
	Effort string // "", "low", "medium", "high", "xhigh", "max"

	Tools              []Tool          // in-process tools, hosted over the control channel
	ToolServerName     string          // MCP server name for Tools; "" = "claudego"
	ExternalMCPServers json.RawMessage // raw mcpServers object, passed through to --mcp-config
	StrictMCPConfig    bool

	IncludePartialMessages bool
	MaxTurns               int
	AssertToolSurface      bool // I6; Pipe sets true
	// OnCanUseTool, when set, registers --permission-prompt-tool stdio so
	// the CLI asks before every tool call. The callback path is
	// characterized only under PermissionMode "default" (the captured
	// configuration); combining it with bypassPermissions is
	// uncharacterized wire territory.
	OnCanUseTool PermissionFunc
	ExtraArgs    []string // appended verbatim; caller-owned escape hatch
}

Options configures a Session.

func Harness

func Harness() Options

Harness is full Claude Code under program control: every knob at the CLI's own default.

func Pipe

func Pipe(systemPrompt string, tools ...Tool) Options

Pipe is the asserted floor profile: no builtin tools, no setting sources, strict MCP config, tool surface asserted at first init (I6), and bypassPermissions so the program's own tools actually run (the program is the permission layer; the pinned-CLI capture characterized dontAsk as deny-without-asking). Adjust fields on the returned value to taste.

type PermissionDecision

type PermissionDecision struct {
	Allow        bool
	UpdatedInput json.RawMessage // optional replacement input when allowed
	Reason       string          // surfaced on denial
}

PermissionDecision is the caller's answer.

type PermissionFunc

type PermissionFunc func(ctx context.Context, req *PermissionRequest) PermissionDecision

PermissionFunc answers a can_use_tool control request.

type PermissionRequest

type PermissionRequest struct {
	ToolName string
	Input    json.RawMessage
	Raw      json.RawMessage // full can_use_tool request, unknown fields retained (I4)
}

PermissionRequest is one tool-permission question from the CLI.

type Profile

type Profile int

Profile names the two supported session postures. A Profile is a starting point produced by Pipe or Harness, not a mode switch: the constructors return an Options value the caller may adjust.

const (
	ProfileHarness Profile = iota // Claude Code's default behaviour under program control
	ProfilePipe                   // stripped, asserted tool surface
)

type ResultEvent

type ResultEvent struct {
	IsError      bool
	ResultText   string
	NumTurns     int
	DurationMS   int64
	TurnUsage    Usage
	ModelUsage   map[string]Usage
	TotalCostUSD float64
	// contains filtered or unexported fields
}

ResultEvent marks the end of a turn.

TurnUsage is the result's `usage` field: main agent loop only, per turn. ModelUsage and TotalCostUSD are cumulative running totals across the session — read the latest result, never sum across results. TotalCostUSD is a client-side estimate (I7).

func (*ResultEvent) Raw

func (e *ResultEvent) Raw() []byte

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session is one live claude child and the stream conversation with it. A Session is not safe for concurrent use; the intended shape is one goroutine sending and ranging events.

func Open

func Open(ctx context.Context, opts Options) (*Session, error)

Open spawns one claude child (I3) speaking bidirectional stream-json, starts the read pump, and completes the initialize control exchange (systemPrompt, appendSystemPrompt, sdkMcpServers — the SDK-oracle path for the system prompt). Per the characterized timing (contracts/events.md), Open does NOT wait for system/init: the wire only emits it after the first user message, so the init snapshot and the I6 tool-surface assertion belong to the first turn.

ctx governs Open itself (bounded by a 30 s initialize deadline when it has none); the session's lifetime belongs to Close, so cancelling ctx after Open returns does not touch the child.

The caller must Close the returned session to reap the child.

func (*Session) Close

func (s *Session) Close() error

Close ends the child's stdin and escalates through the oracle's teardown shape until the child is reaped: stdin EOF → 2 s grace → SIGTERM → 5 s grace → SIGKILL. Idempotent. A signal exit caused by our own escalation is a successful Close, not an error.

func (*Session) ContextUsage

func (s *Session) ContextUsage(ctx context.Context) (*ContextUsage, error)

ContextUsage asks the CLI for its current context-window accounting via the get_context_usage control request.

func (*Session) Cumulative

func (s *Session) Cumulative() CumulativeUsage

Cumulative returns the session's running usage totals as of the latest result event. The zero value (nil ByModel) means no result has carried totals yet.

func (*Session) Events

func (s *Session) Events(ctx context.Context) iter.Seq2[Event, error]

Events yields decoded events from the current stream position until the session ends. Sequential re-ranging resumes where the previous range stopped; concurrent iteration is not supported.

func (*Session) Init

func (s *Session) Init() *InitEvent

Init returns the most recent system/init snapshot, or nil before the first turn: the wire emits init only after a user message (contracts/events.md), so a freshly opened session has none yet.

func (*Session) Interrupt

func (s *Session) Interrupt(ctx context.Context) error

Interrupt ends the in-flight turn via the control channel — never a signal (basis rule). On CLIs advertising interrupt_receipt_v1 (I5, characterized in contracts/events.md) the ack carries a receipt; older CLIs ack bare, and both are success. The interrupted turn still ends with its own error result on the event stream.

func (*Session) Prompt

func (s *Session) Prompt(ctx context.Context, text string) (*ResultEvent, error)

Prompt is Send plus drain-to-result, for simple callers and the driver.

func (*Session) Send

func (s *Session) Send(ctx context.Context, text string) error

Send writes one user turn to the child.

func (*Session) SendRaw

func (s *Session) SendRaw(ctx context.Context, userFrame []byte) error

SendRaw writes one caller-built frame to the child, untouched — the full-fidelity escape hatch for message shapes Send does not model (images, multi-block content). The frame must be one JSON object; NDJSON framing (the trailing newline) is added here.

type StreamEvent

type StreamEvent struct {
	Inner json.RawMessage
	// contains filtered or unexported fields
}

StreamEvent wraps one partial-message event (only with Options.IncludePartialMessages). Inner is the raw BetaRawMessageStreamEvent; Delta and Boundary give the two views callers actually consume.

func (*StreamEvent) Boundary

func (e *StreamEvent) Boundary() Boundary

Boundary returns the structural edge this event marks, if any.

func (*StreamEvent) Delta

func (e *StreamEvent) Delta() (DeltaKind, string)

Delta returns the incremental text this event carries, if any. Unrecognised delta types (signature_delta, future kinds) are DeltaNone.

func (*StreamEvent) Raw

func (e *StreamEvent) Raw() []byte

type Tool

type Tool struct {
	Name        string
	Description string
	InputSchema json.RawMessage // passed through byte-for-byte; nil = {"type":"object"}
	Handler     ToolHandler
}

Tool is one caller-defined tool, hosted in-process and exposed to the model as mcp__<server>__<name> (server defaults to "claudego").

type ToolHandler

type ToolHandler func(ctx context.Context, input json.RawMessage) (*ToolResult, error)

ToolHandler runs one tool call. A returned error becomes an error-flagged tool result — it never fails the session.

There is no library-side per-call timeout: the CLI owns tool-call deadlines (its MCP_TOOL_TIMEOUT env var, effectively unbounded by default). ctx is cancelled when the CLI cancels the call or the session closes; a handler that ignores ctx parks its goroutine until it returns — honor ctx in anything long-running.

type ToolResult

type ToolResult struct {
	Content json.RawMessage // MCP content array
	IsError bool            // flows to the model as data
}

ToolResult is what the model sees back.

func ErrorResult

func ErrorResult(text string) *ToolResult

ErrorResult wraps text as an error-flagged tool result.

func TextResult

func TextResult(text string) *ToolResult

TextResult wraps text as a single-block tool result.

type UnknownEvent

type UnknownEvent struct {
	Type    string
	Subtype string
	// contains filtered or unexported fields
}

UnknownEvent is any frame the decoder does not type. It is data, never an error: the event schema moves roughly 25 releases a month, and a decoder that fails on an unrecognised frame breaks in production within weeks (I4).

func (*UnknownEvent) Raw

func (e *UnknownEvent) Raw() []byte

type Usage

type Usage struct {
	Input         int64
	Output        int64
	CacheRead     int64
	CacheCreation int64
}

Usage is one token-usage measurement. It appears per-turn (a result's snake_case `usage` field) and cumulatively per model (the camelCase `modelUsage` entries); both decode into this struct.

func (Usage) Add

func (u Usage) Add(v Usage) Usage

Add returns the element-wise sum. It exists for callers doing their own per-turn arithmetic; the library's cumulative view never sums.

type UserEvent

type UserEvent struct {
	Message json.RawMessage
	// contains filtered or unexported fields
}

UserEvent is a user-role frame the CLI emits on its own — observed for tool results and interrupt notices.

func (*UserEvent) Raw

func (e *UserEvent) Raw() []byte

Directories

Path Synopsis
cmd
claude-go command
The bridge subcommand: stdio in, stdio out, one session behind it (contracts/bridge-v1.md).
The bridge subcommand: stdio in, stdio out, one session behind it (contracts/bridge-v1.md).
internal
bridge
Package bridge implements bridge protocol v1 (contracts/bridge-v1.md): the NDJSON stdio seam that lets a non-Go host hold a claudego session while all hard logic — transcript prefix-matching, restart decisions, proxy-tool bookkeeping — stays on this side, keeping every host thin.
Package bridge implements bridge protocol v1 (contracts/bridge-v1.md): the NDJSON stdio seam that lets a non-Go host hold a claudego session while all hard logic — transcript prefix-matching, restart decisions, proxy-tool bookkeeping — stays on this side, keeping every host thin.
control
Package control implements the stream-json control channel: the request/response frames that ride the same stdio pipes as events.
Package control implements the stream-json control channel: the request/response frames that ride the same stdio pipes as events.
toolhost
Package toolhost answers the MCP dialect Claude Code speaks to in-process ("sdk") tool servers over the control channel's mcp_message seam.
Package toolhost answers the MCP dialect Claude Code speaks to in-process ("sdk") tool servers over the control channel's mcp_message seam.
wiretap
Package wiretap records what the claude child actually sends to the model API.
Package wiretap records what the claude child actually sends to the model API.
Package streamjson frames the newline-delimited JSON that Claude Code emits under --output-format stream-json and accepts under --input-format stream-json.
Package streamjson frames the newline-delimited JSON that Claude Code emits under --output-format stream-json and accepts under --input-format stream-json.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL