# 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 ```go 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" } ``` ## Multi-turn / Sessions (v0.2) For workflows that need context across turns ("build this with me step by step", "now look at the auth flow", etc.) v0.2 adds a `Session` handle that wraps the server's `/sessions/*` endpoints. Single-turn `Client.Run` is unchanged — Sessions are purely additive. ```go package main import ( "context" "fmt" "log" "os" 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 := context.Background() s, err := client.NewSession(ctx, &cf.SessionOptions{Agent: "claude"}) if err != nil { log.Fatal(err) } defer s.Close(ctx) // idempotent — safe in defer / cleanup paths r1, err := s.Turn(ctx, "Read README.md and summarize") if err != nil { log.Fatal(err) } fmt.Println(r1.Text()) // concat all "text" events r2, err := s.Turn(ctx, "Now look at the auth flow", cf.TurnOption{ Files: []string{"ff_xyz"}, }) if err != nil { log.Fatal(err) } _ = r2 // List sessions visible to the calling token: list, _ := client.ListSessions(ctx) fmt.Println("open sessions:", len(list)) // Inspect server-side state (turn count, timestamps, closed-at): state, _ := client.GetSession(ctx, s.ID()) fmt.Println("turns so far:", state.TurnCount) } ``` ### Lifecycle | Method | Endpoint | Notes | |---|---|---| | `client.NewSession(ctx, opts)` | `POST /sessions` | `opts` may be `nil` (defaults to agent="claude", no meta). | | `s.Turn(ctx, prompt, opts...)` | `POST /sessions/{id}/turn` | Returns `*TurnResult` with the full event batch + stop reason + timing. | | `s.Close(ctx)` | `DELETE /sessions/{id}` | **Idempotent** — second and subsequent calls short-circuit via an atomic flag with no network round-trip. Safe to `defer`. | | `client.ListSessions(ctx)` | `GET /sessions` | Per-token isolation enforced server-side. | | `client.GetSession(ctx, id)` | `GET /sessions/{id}` | Cross-token access surfaces as `*APIError` with `StatusCode==404` (no existence leak). | ### TurnResult.Text() Each turn returns a batch of structured events: `thinking`, `tool_call`, `text`. `Text()` is a convenience that concatenates the `Content` of every `text` event in order — the user-visible reply. Use the `Events` slice directly for tool-call introspection, thinking traces, etc. ```go res, _ := s.Turn(ctx, "...") fmt.Println(res.Text()) // user-visible text only for _, ev := range res.Events { // full structured stream if ev.Type == "tool_call" { log.Printf("tool: %s args=%v", ev.Name, ev.Args) } } ``` ### Concurrent Turn calls Concurrent `s.Turn(ctx, ...)` calls on the **same** session are serialized by a `sync.Mutex` internal to the `Session` value — the server observes turns in the order the SDK dispatches them, never overlapping. The mutex is **per-session, not global**: different sessions on the same `Client` never block each other. This matches what the agent layer wants (a session is a conversation, and two prompts to the same conversation should not race), without serializing unrelated work on the client. ### TurnOption fields ```go type TurnOption struct { Files []string // file_token values from UploadFile / UploadReader TimeoutMs int // 0 = use server default; rounded UP to whole seconds on the wire } ``` `TimeoutMs` is exposed for symmetry with `TurnResult.DurationMs`. The server takes whole seconds (`timeout_secs`); a sub-second value rounds **up** so e.g. `TimeoutMs: 500` becomes `timeout_secs=1`, never `0` (which would mean "use default"). ### Errors The Session surface uses the same error families as v0.1 — no new types. Cross-token / unknown-id 404s are `*APIError` with `StatusCode==404`. Auth failures still go through `ErrAuth`. Transport / context-cancel still wraps into `*TransportError`. ## 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}`. ```go 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. ```go 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: ```go 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`. ```go 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) ```go 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`: ```go 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: ```go 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.