- 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:
|
||
|---|---|---|
| .. | ||
| cmd/cf-cli | ||
| clawdforge_session_test.go | ||
| client.go | ||
| client_test.go | ||
| errors.go | ||
| go.mod | ||
| models.go | ||
| README.md | ||
| session.go | ||
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"
}
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.
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.
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
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}.
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.