- 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
376 lines
11 KiB
Markdown
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.
|