clawdforge/clients/typescript
Kayos 3a590d775e clients/typescript: v0.2 multi-turn Session API
- 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: 940861f
2026-04-29 06:38:55 -07:00
..
examples clients/typescript: initial TypeScript SDK for clawdforge 2026-04-28 22:42:46 -07:00
src clients/typescript: v0.2 multi-turn Session API 2026-04-29 06:38:55 -07:00
tests clients/typescript: v0.2 multi-turn Session API 2026-04-29 06:38:55 -07:00
.gitignore clients/typescript: initial TypeScript SDK for clawdforge 2026-04-28 22:42:46 -07:00
LICENSE clients/typescript: apply audit findings — uploadFile streaming + metadata + validation (15de6e7cc54cfb) 2026-04-28 23:12:27 -07:00
package-lock.json clients/typescript: apply audit findings — uploadFile streaming + metadata + validation (15de6e7cc54cfb) 2026-04-28 23:12:27 -07:00
package.json clients/typescript: v0.2 multi-turn Session API 2026-04-29 06:38:55 -07:00
README.md clients/typescript: v0.2 multi-turn Session API 2026-04-29 06:38:55 -07:00
tsconfig.json clients/typescript: v0.2 multi-turn Session API 2026-04-29 06:38:55 -07:00

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 fetch and AbortController
  • No runtime dependenciestypescript is a dev dep only
  • Strict TypeScript — public types are explicit, result is unknown so you narrow it where you know the shape
  • camelCase TS / snake_case wire — converted at the boundary, you never see timeout_secs or duration_ms in your code
  • Typed errorsForgeAuthError, 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 own package.json, or
  • Import from a .mts / .ts file 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 with ForgeError. The file is streamed via fs.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).