Drop-in Node 18+ client with strict types, native fetch, AbortSignal support, and a typed error hierarchy (ForgeAuthError / ForgeAPIError / ForgeTransportError). Mirrors the existing Python client surface but stays generic — no Sulkta-specific assumptions, suitable for anyone running their own clawdforge instance. - camelCase TS, snake_case wire — converted at the boundary - node:test suite (17 tests) covering healthz, run success/error paths, 502 envelopes, abort/timeout, file upload, and admin token CRUD - tsc --noEmit clean with strict mode + Node16 module resolution |
||
|---|---|---|
| .. | ||
| examples | ||
| src | ||
| tests | ||
| .gitignore | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
clawdforge — TypeScript SDK
A small, dependency-free TypeScript / Node.js SDK for clawdforge:
a LAN-only HTTP service that wraps claude -p subprocess calls behind a
bearer-token-gated REST API.
- Node 18+ — uses native
fetchandAbortController - No runtime dependencies —
typescriptis a dev dep only - Strict TypeScript — public types are explicit,
resultisunknownso you narrow it where you know the shape - camelCase TS / snake_case wire — converted at the boundary, you never
see
timeout_secsorduration_msin your code - Typed errors —
ForgeAuthError,ForgeAPIError,ForgeTransportError - Cancellable — every method takes an optional
signal: AbortSignal
Install
For now, the SDK is consumed straight from this repo subtree (no npm publish):
# from your app's repo
npm install file:/path/to/clawdforge/clients/typescript
Or, if you check the clawdforge repo out alongside your app:
npm install file:../clawdforge/clients/typescript
You can also point at a Git URL, since package.json lives at this path:
npm install "git+https://your-git-host/your-org/clawdforge.git#main:clients/typescript"
Quickstart
import { Forge, ForgeAPIError, ForgeError } from "clawdforge";
const forge = new Forge({
baseUrl: "http://localhost:8800",
token: process.env.CLAWDFORGE_TOKEN!, // a per-app `cf_...` token
});
const h = await forge.healthz();
console.log(h.claudeVersion); // "1.2.3 (Claude Code)" (or null)
const r = await forge.run({
prompt: 'Reply with JSON: {"hello":"world"}',
model: "sonnet",
system: "Be terse.",
timeoutSecs: 60,
});
console.log(r.result, r.durationMs, r.stopReason);
// Narrow the result if you trust the prompt:
type Hello = { hello: string };
const data = r.result as Hello;
console.log(data.hello);
API
All methods are async and return Promises.
new Forge(options: ForgeOptions)
| field | type | default | notes |
|---|---|---|---|
baseUrl |
string |
required | trailing slashes are stripped |
token |
string |
required | per-app cf_... for /run+/files, admin for /admin/* |
defaultTimeoutMs |
number |
120000 |
client-side network timeout; 0 disables |
fetch |
typeof fetch |
globalThis.fetch |
inject a custom fetch (testing, proxies, etc.) |
forge.healthz(): Promise<HealthzResponse>
{ ok: boolean; claudePresent: boolean; claudeVersion: string | null }
forge.run(req: RunRequest): Promise<RunResult>
interface RunRequest {
prompt: string; // required, non-empty
model?: string; // e.g. "sonnet"
system?: string; // append-system-prompt
files?: string[]; // file_tokens from uploadFile()
timeoutSecs?: number; // 5..600, server-side subprocess timeout
signal?: AbortSignal; // cancel the request
}
interface RunResult {
ok: true;
result: unknown; // narrow at call site (object or string)
durationMs: number;
stopReason: string | null; // typically "end_turn"
}
On a server-reported subprocess failure (HTTP 502), the SDK throws a
ForgeAPIError whose body is a RunErrorBody:
interface RunErrorBody {
ok: false;
error: string | null;
stderr: string;
durationMs: number;
stopReason: string | null; // "timeout" | "error" | ...
}
forge.uploadFile(source, opts?): Promise<FileToken>
const ft = await forge.uploadFile("./recipe.png", { ttlSecs: 3600 });
await forge.run({
prompt: "extract recipe data",
files: [ft.fileToken],
});
source can be a string path, a Uint8Array / Buffer, or a Blob. Pass
filename and/or contentType in opts to override defaults.
interface FileToken {
fileToken: string; // "ff_..."
ttlSecs: number; // 60..86400, default 3600
size: number;
}
Admin: createToken, listTokens, revokeToken
These require an admin-bootstrap token in the Forge constructor, not a
per-app token.
const admin = new Forge({ baseUrl, token: process.env.CLAWDFORGE_ADMIN! });
const minted = await admin.createToken({
name: "my-app",
ipCidrs: ["10.0.0.0/8"],
});
console.log(minted.token); // shown ONCE — save it now
for (const t of await admin.listTokens()) {
console.log(t.name, t.enabled, t.lastUsed);
}
await admin.revokeToken("my-app"); // returns true; false if not found
Errors
All SDK errors extend ForgeError. Catch the leaf you care about, or the
base for blanket handling:
import {
ForgeError,
ForgeAuthError,
ForgeAPIError,
ForgeTransportError,
} from "clawdforge";
try {
await forge.run({ prompt: "..." });
} catch (e) {
if (e instanceof ForgeAuthError) {
// 401 / 403 — bearer wrong/revoked, or IP not allow-listed
} else if (e instanceof ForgeAPIError) {
// any other 4xx/5xx; e.status, e.body
if (e.status === 502) {
// subprocess failure: e.body is RunErrorBody
}
} else if (e instanceof ForgeTransportError) {
// network failure, DNS, TLS, abort, or timeout
if (e.aborted) {
// either caller's AbortSignal fired or the SDK's defaultTimeoutMs did
}
} else if (e instanceof ForgeError) {
// input-validation errors thrown before the network
}
}
Cancellation
Every method accepts an optional signal: AbortSignal. On abort the SDK
throws ForgeTransportError with aborted: true:
const ac = new AbortController();
const p = forge.run({ prompt: "long task", signal: ac.signal });
setTimeout(() => ac.abort(), 5_000);
The SDK also enforces a client-side defaultTimeoutMs (120s by default). For
run(), when you pass timeoutSecs, the network timeout is automatically
set to timeoutSecs * 1000 + 30_000, so the HTTP socket outlives the
server-side subprocess timeout — that way the server gets a chance to send
back a clean 502 envelope rather than the client tearing down first.
Develop
cd clients/typescript
npm install
npm run typecheck # tsc --noEmit
npm test # node --test --import tsx tests/*.test.ts
npm run build # emit dist/
The test suite uses node:test with a mock fetch injected via the
fetch constructor option — no live server needed.
License
MIT (see repo root).