- Session class with Symbol.asyncDispose for `await using` ergonomics (ES2024)
- forge.session({ agent }) preferred form; forge.createSession() explicit
- forge.listSessions() / forge.getSession()
- TurnResult / TurnEvent / SessionState types + turnText() helper
- Idempotent Session.close() (200 on re-close server-side)
- tests/sessions.test.ts: 13 tests covering disposal/idempotency/throw/list/state/404/regression
- README "Multi-turn / Sessions (v0.2)" section + fallback try/finally docs
tsconfig.json: lib bumped to add ES2023 + ESNext.Disposable so the
Symbol.asyncDispose / AsyncDisposable types resolve under TS 5.9. Target
stays ES2022 — the disposable runtime hooks are TS-erasable, no runtime
polyfill needed; consumers just need Node 20.4+ at runtime to use the
`await using` form (documented in the README; the v0.1 surface and the
explicit createSession + try/finally fallback continue to work on Node 18+).
package.json: bumped to 0.2.0; engines.node stays >=18 since the v0.1
surface is unchanged. v0.1 /run path unchanged (regression test added).
Spec: memory/spec-clawdforge-v0.2.md
Server core:
|
||
|---|---|---|
| .. | ||
| examples | ||
| src | ||
| tests | ||
| .gitignore | ||
| LICENSE | ||
| package-lock.json | ||
| 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
Module format: ESM-only
This SDK ships ESM only. package.json is "type": "module", the
exports map only resolves an import condition, and there is no CJS
build. Consumers must:
- Use a project with
"type": "module"in their ownpackage.json, or - Import from a
.mts/.tsfile and let their bundler (esbuild, tsup, Vite, etc.) handle interop, or - Use the dynamic
await import("clawdforge")form from a CJS module.
require("clawdforge") will throw ERR_REQUIRE_ESM on Node. If a CJS
build becomes necessary, file an issue — the fix is a second tsc pass
plus a dual exports map, not a deep refactor.
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; validated via new URL() |
token |
string |
required | per-app cf_... for /run+/files, admin for /admin/* |
defaultTimeoutMs |
number |
120000 |
client-side network timeout; 0 disables; negative rejected |
uploadMaxBytes |
number |
104857600 (100 MB) |
size cap for uploadFile(path); 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 to a local regular file. Symlinks are followed to
their target via
fs.stat. Non-regular files (directories, FIFOs, sockets, block/char devices) are rejected withForgeError. The file is streamed viafs.createReadStream— its full contents are not buffered in memory. - a Node
Uint8Array/Buffer - a Web
Blob/File
For string-path uploads, the file size is checked via fs.stat against
ForgeOptions.uploadMaxBytes (default 100 MB) before any bytes are
read; oversized files fail fast with ForgeError and never touch memory
or the network. Set uploadMaxBytes: 0 to disable the cap.
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
Multi-turn / Sessions (v0.2)
For workflows that need context across turns ("read this file… now look at
the auth flow… now write a fix"), the SDK exposes a Session handle backed
by the server's /sessions/* endpoints (themselves backed by ACPX).
The preferred form uses await using
(ES2024 Explicit Resource Management — Node 20.4+ / TypeScript 5.2+). The
session is closed automatically at scope exit, including on throw:
import { Forge, turnText } from "clawdforge";
const forge = new Forge({ baseUrl, token: process.env.CLAWDFORGE_TOKEN! });
{
await using s = await forge.session({ agent: "claude" });
const r1 = await s.turn("Read README.md and summarize");
console.log(turnText(r1));
const r2 = await s.turn("Now look at the auth flow");
console.log(turnText(r2));
} // session.close() runs here automatically (success OR throw)
If your runtime doesn't support await using yet, use the explicit form
with try/finally — same end result, just verbose:
const s = await forge.createSession({ agent: "claude" });
try {
const r = await s.turn("hello");
} finally {
await s.close(); // idempotent — safe to call any number of times
}
forge.session(opts?) / forge.createSession(opts?)
interface CreateSessionOptions {
agent?: string; // default "claude"; ^[a-zA-Z0-9_-]{1,64}$
meta?: Record<string, unknown>; // free-form, round-tripped via SessionState.meta (server-side)
signal?: AbortSignal;
}
session() is sugar over createSession() — same return type. Use
session() when you intend to await using it; use createSession() when
you'll manage the lifetime yourself.
session.turn(prompt, opts?): Promise<TurnResult>
interface TurnOptions {
files?: string[]; // file_tokens from forge.uploadFile()
timeoutSecs?: number; // server-side per-turn timeout, 5..1800
signal?: AbortSignal;
}
interface TurnResult {
ok: boolean;
sessionId: string;
turnIndex: number; // monotonic 1-based
events: TurnEvent[]; // structured event log
stopReason: string; // typically "end_turn"
durationMs: number;
}
interface TurnEvent {
type: "text" | "tool_call" | "thinking" | string; // open enum
content?: string;
name?: string;
args?: Record<string, unknown>;
result?: unknown;
[extra: string]: unknown; // pass-through for server additions
}
The type field is intentionally an open enum — the server may grow
new event kinds (session_update, agent_message_chunk, etc.) and the
SDK will pass them through unchanged. Match on the types you care about
and tolerate the rest.
turnText(result): string
Convenience helper that concatenates the content of every "text" event
in a turn, ignoring tool calls and thinking traces:
const r = await s.turn("Reply with one sentence.");
console.log(turnText(r)); // just the assistant's prose
session.close(opts?): Promise<void>
Closes the session. Idempotent — calling twice is a no-op locally, and the
server itself returns {ok:true, already_closed:true} on a second DELETE.
Safe in finally blocks.
forge.listSessions(opts?) / forge.getSession(id, opts?)
interface SessionState {
sessionId: string;
agent: string;
appName: string; // populated from listSessions(); "" from getSession()
createdAt: number; // unix epoch seconds
lastTurnAt: number | null;
turnCount: number;
closedAt: number | null;
}
const list = await forge.listSessions(); // your token's sessions
const open = await forge.listSessions({ includeClosed: false });
const s = await forge.getSession("sess_abc...");
Cross-token access (a session id you don't own) returns 404, surfaced as
ForgeAPIError(404) — not 403, deliberately, so existence isn't leaked
across apps.
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).