- 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 |
||
|---|---|---|
| .. | ||
| cmd/cf-cli | ||
| client.go | ||
| client_test.go | ||
| errors.go | ||
| go.mod | ||
| models.go | ||
| README.md | ||
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_secsisomitempty— server defaults to itsdefault_timeout_secsconfig when absent. Server validates5 <= n <= 600.resultfield is captured asjson.RawMessagebecause the server returns either parsed JSON or a plain string depending on whatclaude -pproduced.502is the only "logical failure" status the SDK special-cases; any other 4xx/5xx becomes a generic*APIError.Healthzdoes 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.