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

11 KiB

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