clawdforge/clients/go
Kayos 237e2f7c34 clients/go: apply audit findings — fmt + doc + test coverage (3c62613 → new)
- L1: gofmt fix on models.go:81
- L2: rewrite misleading RunFailure doc comment (didn't actually embed APIError)
- L3: tighten Client doc to warn against post-construction field mutation
- L4: errors.New for non-formatting Errorf calls
- L5: add TestUploadFile lifting coverage from 0% → 100% on UploadFile
- L7: add context cancellation mid-multipart test

Audit: memory/clawdforge-audits/go-3c62613.md
2026-04-28 23:08:46 -07:00
..
cmd/cf-cli clients/go: initial Go SDK for clawdforge 2026-04-28 22:36:56 -07:00
client.go clients/go: apply audit findings — fmt + doc + test coverage (3c62613 → new) 2026-04-28 23:08:46 -07:00
client_test.go clients/go: apply audit findings — fmt + doc + test coverage (3c62613 → new) 2026-04-28 23:08:46 -07:00
errors.go clients/go: apply audit findings — fmt + doc + test coverage (3c62613 → new) 2026-04-28 23:08:46 -07:00
go.mod clients/go: initial Go SDK for clawdforge 2026-04-28 22:36:56 -07:00
models.go clients/go: apply audit findings — fmt + doc + test coverage (3c62613 → new) 2026-04-28 23:08:46 -07:00
README.md clients/go: initial Go SDK for clawdforge 2026-04-28 22:36:56 -07:00

clawdforge Go SDK

Idiomatic Go client for the LAN-only clawdforge HTTP service.

Install

This module is hosted on Sulkta's internal Gitea, not GitHub. To go get it you have two reasonable options.

Option 1 — replace directive (vendored / sibling checkout)

In your consumer's go.mod:

require gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go v0.0.0
replace gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go => ../clawdforge/clients/go

Option 2 — GOPRIVATE + git insteadOf

export GOPRIVATE=gitea.sulkta.com/*
git config --global url."http://192.168.0.5:3001/".insteadOf "https://gitea.sulkta.com/"
go get gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go@latest

Either way, the module path is gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go and the recommended import alias is cf.

Quickstart

package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "time"

    cf "gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go"
)

func main() {
    client := cf.New("http://192.168.0.5:8800", os.Getenv("CLAWDFORGE_TOKEN"))

    ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
    defer cancel()

    h, err := client.Healthz(ctx)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("claude:", h.ClaudeVersion)

    res, err := client.Run(ctx, cf.RunRequest{
        Prompt: `Reply with JSON: {"hello": "world"}`,
        Model:  "sonnet",
    })
    if err != nil {
        log.Fatal(err)
    }

    var data map[string]string
    if err := res.AsJSON(&data); err != nil {
        log.Fatal(err)
    }
    fmt.Println(data["hello"]) // "world"
}

API surface

The SDK mirrors the FastAPI surface in clawdforge/server.py 1:1.

New(baseURL, token string) *Client

Constructs a Client with a default *http.Client (no wall-clock timeout — control timing via context). Trailing slash on baseURL is trimmed.

NewWithClient(baseURL, token string, hc *http.Client) *Client

Same as New but lets you pass a tuned *http.Client (custom transport, proxy, TLS config, connection pool, etc.).

(*Client).Healthz(ctx) (*Healthz, error)

GET /healthz — liveness + claude --version smoke. Returns Healthz{OK, ClaudePresent, ClaudeVersion}.

h, err := client.Healthz(ctx)

(*Client).Run(ctx, RunRequest) (*RunResult, error)

POST /run. Sends a prompt, returns whatever claude -p --output-format json produced. The Result field is json.RawMessage because claude may return parsed JSON or plain text.

res, err := client.Run(ctx, cf.RunRequest{
    Prompt:      "Sterilize: 'about 2 cups cooked white rice'",
    Model:       "sonnet",
    System:      "Reply ONLY with JSON.",
    Files:       []string{"ff_abc123"},
    TimeoutSecs: 30,
})

(*RunResult).AsJSON(out any) error

Unmarshal Result into a typed value:

var ing struct {
    Qty   float64 `json:"qty"`
    Unit  string  `json:"unit"`
    Food  string  `json:"food"`
}
if err := res.AsJSON(&ing); err != nil {
    log.Fatal(err)
}

(*RunResult).AsText() (string, error)

Materialize Result as a string. Works for both quoted-string results ("hello") and structured JSON (returns the raw JSON encoding).

(*Client).UploadFile(ctx, path string, ttlSecs int) (*FileToken, error)

POST /files — multipart upload, streamed via io.Pipe so large files don't buffer in memory. ttlSecs == 0 uses the server default (3600); valid range is 60..86400. Returns FileToken{FileToken, TTLSecs, Size}. Pass ft.FileToken in RunRequest.Files.

ft, err := client.UploadFile(ctx, "/srv/recipes/recipe.png", 3600)
res, err := client.Run(ctx, cf.RunRequest{
    Prompt: "Extract recipe data",
    Files:  []string{ft.FileToken},
})

(*Client).UploadReader(ctx, filename string, r io.Reader, ttlSecs int) (*FileToken, error)

Same as UploadFile but takes any io.Reader and a filename — useful for in-memory blobs or piped data.

Admin (require admin bootstrap token)

admin := cf.New("http://192.168.0.5:8800", os.Getenv("CLAWDFORGE_ADMIN_TOKEN"))

tok, err := admin.CreateToken(ctx, cf.CreateTokenRequest{
    Name:    "petalparse",
    IPCidrs: []string{"172.24.0.0/16"},
})
// tok.Token is the plaintext — store it now, you can't see it again.

list, err := admin.ListTokens(ctx)

err = admin.RevokeToken(ctx, "petalparse")

Error handling

Three typed error families. Use errors.Is / errors.As:

res, err := client.Run(ctx, cf.RunRequest{Prompt: "x"})
if err != nil {
    switch {
    case errors.Is(err, cf.ErrAuth):
        // 401/403 — bad bearer or IP not allowlisted
    case errors.Is(err, context.DeadlineExceeded),
         errors.Is(err, context.Canceled):
        // caller's context died

    default:
        var rf *cf.RunFailure
        var apiErr *cf.APIError
        var transportErr *cf.TransportError

        switch {
        case errors.As(err, &rf):
            // 502 — clawdforge took the request but `claude` failed
            // (timeout, non-zero exit). rf.StopReason in {"timeout","error"}.
            log.Printf("claude failed after %dms: %s", rf.DurationMS, rf.Err)
        case errors.As(err, &apiErr):
            // generic 4xx/5xx — apiErr.StatusCode + apiErr.Body
        case errors.As(err, &transportErr):
            // network/transport — DNS, connect refused, TLS, EOF, ...
        default:
            log.Printf("unexpected: %v", err)
        }
    }
}
Error When Detect with
cf.ErrAuth HTTP 401, 403 errors.Is(err, cf.ErrAuth)
*cf.RunFailure HTTP 502 from /run errors.As(err, &rf)
*cf.APIError other HTTP >= 400 errors.As(err, &apiErr)
*cf.TransportError DNS / connect / TLS / EOF / context errors.As(err, &te)

Context discipline

Every public method takes context.Context as the first argument. The SDK's underlying *http.Client does not set a wall-clock timeout — long claude runs (curate jobs, big recipe corpora) can legitimately take multiple minutes. Set timeouts on the caller side:

ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSecs+30)*time.Second)
defer cancel()

The +30s margin over timeout_secs mirrors the Python Forge client so the HTTP call doesn't bail while clawdforge is still working.

CLI demo

cmd/cf-cli is a small demo binary:

go install gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go/cmd/cf-cli@latest

export CLAWDFORGE_URL=http://192.168.0.5:8800
export CLAWDFORGE_TOKEN=cf_...

cf-cli health
cf-cli run 'Reply with JSON: {"hello":"world"}' sonnet 30
cf-cli upload /path/to/file.png 3600
cf-cli tokens

Tests

cd clients/go
go test ./...

All tests use httptest.NewServer — no network required.

Ambiguities resolved (mirrored from server.py)

  • RunRequest.timeout_secs is omitempty — server defaults to its default_timeout_secs config when absent. Server validates 5 <= n <= 600.
  • result field is captured as json.RawMessage because the server returns either parsed JSON or a plain string depending on what claude -p produced.
  • 502 is the only "logical failure" status the SDK special-cases; any other 4xx/5xx becomes a generic *APIError.
  • Healthz does not require a bearer token (the server only enforces the global IP allowlist on it). The SDK still sends the token if present — the server ignores it.
  • File uploads stream via io.Pipe + mime/multipart — neither the file body nor the multipart envelope is buffered in memory.