// Package clawdforge is the Go SDK for the LAN-only clawdforge HTTP service — // a thin REST wrapper around `claude -p` subprocess invocations. // // Example: // // client := clawdforge.New("http://192.168.0.5:8800", os.Getenv("CLAWDFORGE_TOKEN")) // ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) // defer cancel() // res, err := client.Run(ctx, clawdforge.RunRequest{Prompt: "hi"}) // // All public methods take a context.Context as the first argument. // Cancellation and per-request timeouts come from the caller's context; // the underlying *http.Client is left without a wall-clock timeout so // long claude runs aren't cut off prematurely. package clawdforge import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "mime/multipart" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" ) // Client is a handle to a clawdforge instance. Construct with New or // NewWithClient. Methods are safe for concurrent use; do not mutate // BaseURL, Token, or HTTPClient after construction. type Client struct { BaseURL string Token string HTTPClient *http.Client } // New returns a Client configured against baseURL with the given bearer // token and a default *http.Client (no wall-clock timeout — control // timeouts via context.Context on each call). // // baseURL should NOT include a trailing slash; if one is present it is // trimmed. func New(baseURL, token string) *Client { return NewWithClient(baseURL, token, &http.Client{}) } // NewWithClient is like New but takes a caller-supplied *http.Client so // transport, proxy, TLS, and connection-pool settings can be tuned. func NewWithClient(baseURL, token string, hc *http.Client) *Client { if hc == nil { hc = &http.Client{} } return &Client{ BaseURL: strings.TrimRight(baseURL, "/"), Token: token, HTTPClient: hc, } } // ---------- public methods -------------------------------------------------- // Healthz issues GET /healthz. Does not require a bearer token, but the // caller's IP must satisfy the global allowlist on the server. func (c *Client) Healthz(ctx context.Context) (*Healthz, error) { req, err := c.newRequest(ctx, http.MethodGet, "/healthz", nil, "") if err != nil { return nil, err } var out Healthz if err := c.do(req, &out); err != nil { return nil, err } return &out, nil } // Run issues POST /run. // // On HTTP 200 it returns a *RunResult. On HTTP 502 (clawdforge accepted // the request but claude failed) it returns a *RunFailure error — use // errors.As to extract it. Auth failures (401/403) return ErrAuth. // Other 4xx/5xx return *APIError. func (c *Client) Run(ctx context.Context, body RunRequest) (*RunResult, error) { if body.Prompt == "" { return nil, errors.New("clawdforge: Run: prompt is required") } buf, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("clawdforge: marshal RunRequest: %w", err) } req, err := c.newRequest(ctx, http.MethodPost, "/run", bytes.NewReader(buf), "application/json") if err != nil { return nil, err } var out RunResult if err := c.do(req, &out); err != nil { return nil, err } return &out, nil } // UploadFile streams a file from disk to POST /files and returns the // resulting *FileToken. The token can be passed in RunRequest.Files for // subsequent /run calls. // // ttlSecs is clamped server-side to [60, 86400]; pass 0 to use the // server default of 3600. func (c *Client) UploadFile(ctx context.Context, path string, ttlSecs int) (*FileToken, error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("clawdforge: open %s: %w", path, err) } defer f.Close() return c.uploadReader(ctx, filepath.Base(path), f, ttlSecs) } // UploadReader is like UploadFile but takes an io.Reader and a filename. // Useful for in-memory uploads or piping from another source. func (c *Client) UploadReader(ctx context.Context, filename string, r io.Reader, ttlSecs int) (*FileToken, error) { return c.uploadReader(ctx, filename, r, ttlSecs) } // CreateToken mints a new per-app token. The plaintext token is in the // returned AppToken.Token and will not be retrievable again. Requires // the admin bootstrap token. func (c *Client) CreateToken(ctx context.Context, body CreateTokenRequest) (*AppToken, error) { if body.Name == "" { return nil, errors.New("clawdforge: CreateToken: name is required") } buf, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("clawdforge: marshal CreateTokenRequest: %w", err) } req, err := c.newRequest(ctx, http.MethodPost, "/admin/tokens", bytes.NewReader(buf), "application/json") if err != nil { return nil, err } var out AppToken if err := c.do(req, &out); err != nil { return nil, err } return &out, nil } // ListTokens returns the configured app tokens (no plaintexts). Requires // the admin bootstrap token. func (c *Client) ListTokens(ctx context.Context) ([]AppToken, error) { req, err := c.newRequest(ctx, http.MethodGet, "/admin/tokens", nil, "") if err != nil { return nil, err } var out TokenList if err := c.do(req, &out); err != nil { return nil, err } return out.Tokens, nil } // RevokeToken deletes the token with the given name. Requires the admin // bootstrap token. func (c *Client) RevokeToken(ctx context.Context, name string) error { if name == "" { return errors.New("clawdforge: RevokeToken: name is required") } req, err := c.newRequest(ctx, http.MethodDelete, "/admin/tokens/"+url.PathEscape(name), nil, "") if err != nil { return err } return c.do(req, nil) } // ---------- internals ------------------------------------------------------- func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Request, error) { u := c.BaseURL + path req, err := http.NewRequestWithContext(ctx, method, u, body) if err != nil { return nil, &TransportError{Op: "build-request", Err: err} } if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } if contentType != "" { req.Header.Set("Content-Type", contentType) } req.Header.Set("Accept", "application/json") return req, nil } // do executes req, decodes a JSON response into out (if non-nil), and // translates any non-2xx status into the appropriate typed error. func (c *Client) do(req *http.Request, out any) error { resp, err := c.HTTPClient.Do(req) if err != nil { // context cancellation / deadline exceeded show up here too; // preserve them via Unwrap so errors.Is works. return &TransportError{Op: req.Method + " " + req.URL.Path, Err: err} } defer resp.Body.Close() // Cap the body read at 8 MiB to keep error paths bounded; success // payloads from /run can be larger so we don't cap on the happy path. if resp.StatusCode >= 400 { bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) return c.translateError(req, resp.StatusCode, bodyBytes) } if out == nil { // Drain so the connection can be reused. _, _ = io.Copy(io.Discard, resp.Body) return nil } if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return &TransportError{Op: "decode " + req.URL.Path, Err: err} } return nil } func (c *Client) translateError(req *http.Request, status int, body []byte) error { switch status { case http.StatusUnauthorized, http.StatusForbidden: return fmt.Errorf("%w: HTTP %d: %s", ErrAuth, status, summarizeBody(body)) case http.StatusBadGateway: // /run failure shape: {"ok":false, "error":"...", "stderr":"...", "duration_ms":..., "stop_reason":"..."} // Only treat it as RunFailure if the body matches that shape. if req.URL.Path == "/run" { var rf RunFailure if err := json.Unmarshal(body, &rf); err == nil && rf.Err != "" { rf.StatusCode = status return &rf } } } return &APIError{ StatusCode: status, Body: string(body), Message: summarizeBody(body), } } // summarizeBody pulls a short message out of a JSON error body. Looks for // "error" or "detail" keys; falls back to the raw body trimmed. func summarizeBody(body []byte) string { if len(body) == 0 { return "" } var m map[string]any if err := json.Unmarshal(body, &m); err == nil { for _, k := range []string{"error", "detail", "message"} { if v, ok := m[k]; ok { if s, ok := v.(string); ok { return s } } } } s := strings.TrimSpace(string(body)) if len(s) > 500 { s = s[:500] + "..." } return s } // ---------- multipart upload ------------------------------------------------ // uploadReader streams to /files via multipart/form-data without buffering // the whole file in memory. It uses an io.Pipe + goroutine pattern so the // HTTP request body is consumed as fast as the upstream can read it. func (c *Client) uploadReader(ctx context.Context, filename string, r io.Reader, ttlSecs int) (*FileToken, error) { pr, pw := io.Pipe() mw := multipart.NewWriter(pw) // Goroutine writes the multipart payload into the pipe; the http // client reads from the other end and ships it upstream. errCh := make(chan error, 1) go func() { defer pw.Close() defer mw.Close() if ttlSecs > 0 { if err := mw.WriteField("ttl_secs", strconv.Itoa(ttlSecs)); err != nil { errCh <- err _ = pw.CloseWithError(err) return } } fw, err := mw.CreateFormFile("file", filename) if err != nil { errCh <- err _ = pw.CloseWithError(err) return } if _, err := io.Copy(fw, r); err != nil { errCh <- err _ = pw.CloseWithError(err) return } errCh <- nil }() req, err := c.newRequest(ctx, http.MethodPost, "/files", pr, mw.FormDataContentType()) if err != nil { _ = pr.Close() return nil, err } var out FileToken if err := c.do(req, &out); err != nil { // Drain the producer so the goroutine doesn't leak. _ = pr.CloseWithError(err) <-errCh return nil, err } if perr := <-errCh; perr != nil { return nil, &TransportError{Op: "multipart-write", Err: perr} } return &out, nil }