- 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
251 lines
8.2 KiB
Markdown
251 lines
8.2 KiB
Markdown
# 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<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`.
|
|
|
|
```ruby
|
|
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<String>, 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<TurnEvent>)
|
|
- `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.
|