# clawdforge — Ruby SDK A small, dependency-free Ruby client for the [clawdforge](../../) HTTP service. clawdforge is a LAN-only HTTP service that wraps `claude -p` subprocess calls behind a bearer-token-gated REST API. This SDK is a thin idiomatic wrapper around that API. - **Stdlib only at runtime** — `net/http` + `json`. No `httparty`, no `faraday`. - **Ruby 3.0+** — keyword args, frozen string literals. - **Stable public surface** — `healthz`, `run`, `upload_file`, `create_token`, `list_tokens`, `revoke_token` (v0.1) plus `session` / `create_session` / `list_sessions` / `get_session` for multi-turn (v0.2). ## Install From a path checkout (typical inside the clawdforge monorepo): ```ruby # Gemfile gem "clawdforge", path: "clients/ruby" ``` Or from a built gem: ```sh cd clients/ruby gem build clawdforge.gemspec gem install ./clawdforge-0.1.0.gem ``` ## Quickstart ```ruby require "clawdforge" forge = Clawdforge::Client.new( base_url: "http://localhost:8800", token: ENV.fetch("CLAWDFORGE_TOKEN"), ) # 1) liveness health = forge.healthz puts health["claude_version"] # 2) run a prompt result = forge.run( prompt: 'Reply with JSON: {"hello": "world"}', model: "sonnet", # optional, default "sonnet" system: "Be terse.", # optional timeout_secs: 60, # optional, default 120, server range 5..600 ) puts result.duration_ms puts result.stop_reason # `result.result` is parsed JSON (Hash) when the model returned valid JSON, # otherwise the raw String. puts result.result["hello"] # 3) upload a file, then attach it to a /run call ft = forge.upload_file("./recipe.png", ttl_secs: 3600) forge.run(prompt: "extract recipe data", files: [ft.file_token]) ``` ## Multi-turn / Sessions (v0.2) `/run` is single-turn — one prompt in, one result out. For follow-ups ("now look at auth flow", "try X… now try Y") use a session. Sessions are backed server-side by ACPX and persist context across turns until you close them (or the TTL sweeper does). ### Block form (preferred) The block form auto-closes the session at block exit, even if the block raised. This is the safest pattern. ```ruby forge.session(agent: "claude") do |s| r1 = s.turn("Read README.md and summarize") puts r1.text # convenience: concats all "text" events r2 = s.turn("Now look at the auth flow") puts r2.text end # session is closed here, even on exception ``` ### Manual form When you need the Session to outlive the call site (background worker, long-lived handle), use `create_session` and close in `ensure`. ```ruby s = forge.create_session(agent: "claude") begin r = s.turn("hello") puts r.text ensure s.close # idempotent — safe to call from ensure without rescue end ``` `Session#close` is idempotent in two layers: 1. **Client-side short-circuit** — second call returns immediately, no HTTP. 2. **Server-side idempotent DELETE** — re-closing a closed session returns `{"ok": true, "already_closed": true}` and 200. If the close-time HTTP call raises (transport error, etc.), the `@closed` flag is rolled back so the caller can retry. ### Listing and inspecting sessions ```ruby forge.list_sessions # => Array forge.list_sessions(include_closed: false) forge.get_session(s.id) # => SessionState ``` ### Tokens stay redacted `Session` holds a back-reference to the `Client` (which holds the bearer token). `Session#inspect`, `#to_s`, and `pretty_print` all redact that reference — the bearer never lands in logs, exception traces, irb output, or `pp` dumps. Same pattern as `Client` and `AppToken`. ```ruby s.inspect # => # ``` ### Errors specific to sessions | Error | When | |---|---| | `Clawdforge::ClosedSessionError` | `s.turn(...)` after `s.close` (client-side gate). Subclass of `Clawdforge::Error`. | | `Clawdforge::APIError(404)` | Unknown `session_id`, OR id belongs to another token (the server returns 404 not 403 to avoid leaking session existence across tokens). | | `Clawdforge::APIError(410)` | Server has already closed the session (TTL swept it, container restarted, etc.). Open a new one. | ## Public API ### `Clawdforge::Client.new(base_url:, token:, **opts)` | keyword | type | default | meaning | |-----------------------|---------|---------|--------------------------------------------------| | `base_url:` | String | — | e.g. `"http://localhost:8800"`. Required. | | `token:` | String | — | bearer token. Required. | | `default_model:` | String | `"sonnet"` | model used when `run` doesn't supply one. | | `default_timeout:` | Integer | `120` | `timeout_secs` used when `run` doesn't supply. | | `http_timeout_margin:`| Integer | `30` | added to subprocess timeout for HTTP timeout. | | `http_client:` | Net::HTTP | `nil` | inject a pre-built Net::HTTP (mostly for tests). | ### `#healthz` Returns a Hash: `{"ok" => Bool, "claude_present" => Bool, "claude_version" => String | nil}`. ### `#run(prompt:, model: nil, system: nil, files: nil, timeout_secs: nil) -> RunResult` - `prompt` (String, required, non-empty) - `model` (String, optional) - `system` (String, optional) - `files` (Array, optional) — file tokens from `upload_file` - `timeout_secs` (Integer, optional, server range 5..600) `RunResult` is a Struct with reader methods: - `ok` (Bool) - `result` (Hash | String) — parsed JSON if the model returned valid JSON, raw String otherwise - `duration_ms` (Integer) - `stop_reason` (String | nil) ### `#upload_file(path, ttl_secs: 3600, filename: nil, content_type: "application/octet-stream") -> FileToken` Streams the file in 1 MiB chunks via `IO.copy_stream`. `FileToken` carries `file_token`, `ttl_secs`, `size`. ### Admin methods (admin-bootstrap-token gated) - `#create_token(name, ip_cidrs: nil) -> AppToken` - `#list_tokens -> Array` - `#revoke_token(name) -> true` (raises `APIError(404)` if not found) `AppToken`'s `token` field holds the plaintext bearer **only** at create time; on list responses it is `nil` (the server stores only a sha256 hash). ### Session methods (v0.2) - `#session(agent: "claude", meta: nil) { |s| ... }` — block form, auto-close. Without a block, returns the open Session (manual lifecycle). - `#create_session(agent: "claude", meta: nil) -> Session` — manual lifecycle. - `#list_sessions(include_closed: true) -> Array` - `#get_session(id) -> SessionState` `Session` instance methods: - `#turn(prompt, files: nil, timeout_secs: nil) -> TurnResult` - `#state -> SessionState` - `#close` — idempotent - `#closed? -> Boolean` - `#id`, `#agent`, `#created_at`, `#cwd` — readers `TurnResult` is a Struct with: - `ok` (Bool) - `session_id` (String) - `turn_index` (Integer) — 1-indexed turn count after this turn - `events` (Array) - `stop_reason` (String | nil) - `duration_ms` (Integer) - `#text -> String` — concatenates `content` of every `type: "text"` event `TurnEvent` shape (keyword-init Struct): - `type` — one of `"thinking"`, `"tool_call"`, `"text"`, … - `content` — populated for `text` and `thinking` - `name`, `args`, `result` — populated for `tool_call` ## Errors ``` Clawdforge::Error ├── Clawdforge::TransportError # connection refused, DNS, TCP timeout, TLS, … └── Clawdforge::APIError # 4xx / 5xx, exposes #status and #body └── Clawdforge::AuthError # 401 / 403 ``` Catch `Clawdforge::Error` to handle the whole family. On `APIError`, inspect `.status` (Integer) and `.body` (Hash if JSON, String otherwise). ```ruby begin forge.run(prompt: "hi", timeout_secs: 5) rescue Clawdforge::AuthError => e warn "bad token: #{e.message}" rescue Clawdforge::APIError => e warn "server said #{e.status}: #{e.body.inspect}" rescue Clawdforge::TransportError => e warn "couldn't reach forge: #{e.message}" end ``` No retry logic is built in. clawdforge runs are not idempotent (they spawn `claude -p`), so retry policy belongs with the caller. ## Development ```sh bundle install bundle exec rake test # runs Minitest with WebMock bundle exec rake build # builds clawdforge-X.Y.Z.gem ``` ## License MIT.