clawdforge/clients/ruby/README.md
Kayos 99173353bb clients/ruby: v0.2 multi-turn Session API
- Session class wrapping a session_id; block form `forge.session do |s|`
- Idempotent Session#close (in-memory short-circuit + server is also
  idempotent server-side; @closed rolled back on transport failure so
  retry is safe)
- Client#create_session / #list_sessions / #get_session
- TurnResult#text helper concatenates text events (skips thinking,
  tool_call, and nil contents)
- Session#inspect / #to_s / #pretty_print redact the embedded client
  reference so the bearer never leaks via logs / pp / irb / backtraces
- test/test_session.rb: 26 tests covering block-form auto-close on
  return + on exception + with-failing-close-must-not-mask-block-error,
  manual lifecycle, close idempotency (single DELETE on N closes),
  transport-failure rollback, turn round-trip + files/timeout/auth,
  closed-session guard, empty-prompt guard, TurnResult#text edge cases,
  list/get state, cross-token 404, server 410, redaction in inspect /
  to_s / PP, and a v0.1 #run regression smoke

v0.1 surface (#run, #upload_file, #create_token, #list_tokens,
#revoke_token, #healthz) is byte-identical — purely additive.

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

8.2 KiB

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 runtimenet/http + json. No httparty, no faraday.
  • Ruby 3.0+ — keyword args, frozen string literals.
  • Stable public surfacehealthz, 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):

# Gemfile
gem "clawdforge", path: "clients/ruby"

Or from a built gem:

cd clients/ruby
gem build clawdforge.gemspec
gem install ./clawdforge-0.1.0.gem

Quickstart

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.

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.

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

forge.list_sessions               # => Array<SessionState>
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.

s.inspect
# => #<Clawdforge::Session id="sess_abc123" agent="claude" closed=false client=[REDACTED]>

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<AppToken>
  • #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<SessionState>
  • #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).

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

bundle install
bundle exec rake test    # runs Minitest with WebMock
bundle exec rake build   # builds clawdforge-X.Y.Z.gem

License

MIT.