- 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:
|
||
|---|---|---|
| .. | ||
| examples | ||
| lib | ||
| test | ||
| .gitignore | ||
| clawdforge.gemspec | ||
| Gemfile | ||
| Rakefile | ||
| README.md | ||
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. Nohttparty, nofaraday. - Ruby 3.0+ — keyword args, frozen string literals.
- Stable public surface —
healthz,run,upload_file,create_token,list_tokens,revoke_token(v0.1) plussession/create_session/list_sessions/get_sessionfor 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:
- Client-side short-circuit — second call returns immediately, no HTTP.
- 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 fromupload_filetimeout_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 otherwiseduration_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(raisesAPIError(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 turnevents(Array)stop_reason(String | nil)duration_ms(Integer)#text -> String— concatenatescontentof everytype: "text"event
TurnEvent shape (keyword-init Struct):
type— one of"thinking","tool_call","text", …content— populated fortextandthinkingname,args,result— populated fortool_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.