clawdforge/clients/go/README.md
Kayos 41a522a469 clients/go: v0.2 multi-turn Session API
- Session struct with idempotent Close(ctx) (atomic.Bool short-circuit)
- Client.NewSession(ctx, opts) / ListSessions(ctx) / GetSession(ctx, id)
- TurnResult.Text() helper concatenates text events
- Per-session sync.Mutex serializes concurrent Turn calls
- clawdforge_session_test.go: 9 tests
- README "Multi-turn / Sessions (v0.2)" section

v0.1 Run path unchanged.

Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
2026-04-29 06:34:12 -07:00

376 lines
11 KiB
Markdown

# 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.