Reference
Client Libraries

Client Libraries

To build tools on Hankweave, you need to connect to its WebSocket API. This guide provides patterns and reference implementations for building robust clients. It focuses on practical application, while the formal protocol specification lives in the WebSocket Protocol.

🎯

Who is this for? Developers building integrations: custom dashboards, CI/CD tools, monitoring systems, or alternative interfaces. If you just need the protocol spec, see WebSocket Protocol.

Reference Implementation

Hankweave's test suite includes TestWSClient, a battle-tested WebSocket client you can use as a foundation. It handles handshakes, event buffering, and the waiting patterns required for any real integration.

The Core Challenge: A Race Condition

A robust client must handle a subtle race condition: an event can arrive before you're ready to listen for it. If you wait for a codon.completed event, but it fired miliseconds before your wait handler was attached, you'll hang forever.

The solution is to buffer all incoming events. Before waiting for a new event, you first check the buffer. This guarantees you won't miss events that arrived while your code was doing something else.

Client Structure

Here is the structure for a client that implements this pattern. It includes an event buffer, promise-based waiters, and connection logic.

Text
import { WebSocket } from "ws";
 
class HankweaveClient {
  private ws: WebSocket | null = null;
  private events: ServerEvent[] = []; // Buffer for all incoming events
  private waiters = new Map<string, ((event: ServerEvent) => void)[]>();
  private subscribers = new Set<(event: ServerEvent) => void>();
 
  private connected = false;
  private handshakeComplete = false;
  private clientId: string | null = null;
  private grantedMode: ClientMode | null = null;
 
  // Connection and command methods will go here...
}

The events array is the safety net that solves the race condition. Every event is pushed into it, ensuring nothing is lost.

Connecting with Retries

Production clients need retry logic. A server might be slow to start, especially when launched via npx, and network connections can be unreliable.

Text
async connectWithRetry(
  port: number,
  options: {
    mode?: ClientMode;
    maxRetries?: number;
    retryDelay?: number;
    timeout?: number;
  } = {}
): Promise<void> {
  const {
    mode = ClientMode.READANDWRITE,
    maxRetries = 30,
    retryDelay = 2000,
    timeout = 10000,
  } = options;
 
  let lastError: Error | null = null;
 
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      console.log(`Connection attempt ${attempt}/${maxRetries}...`);
      await this.connect(port, { mode, timeout });
      return; // Success
    } catch (error) {
      lastError = error as Error;
      if (attempt < maxRetries) {
        await new Promise((resolve) => setTimeout(resolve, retryDelay));
      }
    }
  }
 
  throw new Error(
    `Failed to connect after ${maxRetries} attempts: ${lastError?.message}`
  );
}

Handshake Flow

The handshake is critical. It determines your access level and can retrieve historical events so you don't miss anything that happened before you connected. Always complete the handshake before sending commands.

Text
async connect(
  port: number,
  options: { mode?: ClientMode; timeout?: number } = {}
): Promise<void> {
  const { mode = ClientMode.READANDWRITE, timeout = 10000 } = options;
 
  return new Promise((resolve, reject) => {
    const timeoutId = setTimeout(() => {
      reject(new Error(`Connection timed out after ${timeout}ms`));
    }, timeout);
 
    this.ws = new WebSocket(`ws://localhost:${port}`);
 
    this.ws.onopen = () => {
      this.connected = true;
      // Handshake immediately on connect
      this.performHandshake(mode)
        .then(() => {
          clearTimeout(timeoutId);
          resolve();
        })
        .catch(reject);
    };
 
    this.ws.onmessage = (message) => {
      const event = JSON.parse(message.data.toString()) as ServerEvent;
      this.events.push(event); // Buffer all events
      this.notifyWaiters(event);
      this.subscribers.forEach((callback) => callback(event));
    };
 
    this.ws.onerror = (err) => reject(err);
    this.ws.onclose = () => {
      this.connected = false;
      this.handshakeComplete = false;
    };
  });
}
 
private async performHandshake(mode: ClientMode): Promise<void> {
  // sendPreviousEvents: true retrieves all events from the start of the run.
  // This ensures you don't miss anything that happened before connecting.
  this.ws?.send(
    JSON.stringify({
      type: "handshake",
      data: { mode, sendPreviousEvents: true },
    })
  );
 
  const response = (await this.waitForEvent(
    "handshake.response",
    5000
  )) as HandshakeResponse;
 
  this.clientId = response.data.clientId;
  this.grantedMode = response.data.mode;
  this.handshakeComplete = true;
 
  if (response.data.eventHistory?.length) {
    this.events.push(...response.data.eventHistory);
  }
}

Waiting for Events

Most integrations need to wait for specific events, like codon completion or errors. The pattern below handles checking the buffer first, timeouts, and filtering.

Text
async waitForEvent(
  type: string,
  timeoutMs: number = 30000,
  filter?: (event: ServerEvent) => boolean
): Promise<ServerEvent> {
  // 1. Check the buffer first in case the event has already arrived.
  const existingEvent = this.events.find((e) => {
    const typeMatches = type === "*" || e.type === type;
    const filterPasses = !filter || filter(e);
    return typeMatches && filterPasses;
  });
 
  if (existingEvent) {
    return existingEvent;
  }
 
  // 2. If not in the buffer, wait for a future event.
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      // Clean up the waiter on timeout
      this.removeWaiter(type, waiter);
      reject(new Error(`Timeout waiting for event: ${type}`));
    }, timeoutMs);
 
    const waiter = (event: ServerEvent) => {
      if (!filter || filter(event)) {
        clearTimeout(timer);
        this.removeWaiter(type, waiter);
        resolve(event);
      }
    };
 
    this.addWaiter(type, waiter);
  });
}

Example: Waiting for a Codon to Complete

The most common pattern is to start a codon and wait for it to finish.

Text
async waitForCodonCompletion(
  codonId: string,
  timeout: number = 120000
): Promise<CodonCompletedEvent> {
  const event = await this.waitForEvent(
    "codon.completed",
    timeout,
    (e) => (e as CodonCompletedEvent).data?.codonId === codonId
  );
  return event as CodonCompletedEvent;
}
 
// Usage:
this.sendCommand({
  id: "cmd-1",
  type: "codon.start",
  data: { codonId: "generate-schema" },
});
 
const result = await client.waitForCodonCompletion("generate-schema");
if (result.data.success) {
  console.log(`Completed in ${result.data.duration}ms, cost: $${result.data.cost}`);
} else {
  console.error(`Failed: ${result.data.failureReason?.message}`);
}

Example: Waiting for Events After a Timestamp

When re-running a process, you often need to ignore old events and only wait for new ones. You can add a timestamp filter to waitForEvent:

Text
async waitForEventAfter(
  type: string,
  timestamp: string, // ISO 8601 string
  timeoutMs: number = 30000,
  filter?: (event: ServerEvent) => boolean
): Promise<ServerEvent> {
  const combinedFilter = (e: ServerEvent) => {
    const isAfter = e.timestamp > timestamp;
    const originalFilterPasses = !filter || filter(e);
    return isAfter && originalFilterPasses;
  };
 
  // Re-use the main waitForEvent with the new combined filter
  return this.waitForEvent(type, timeoutMs, combinedFilter);
}

Sending Commands

Commands can only be sent after a successful handshake. Always check for this state before sending.

Text
sendCommand(command: ClientCommand): void {
  if (!this.ws || !this.connected) {
    throw new Error("WebSocket is not connected.");
  }
  if (!this.handshakeComplete) {
    throw new Error("Handshake not completed. Cannot send commands.");
  }
  this.ws.send(JSON.stringify(command));
}

Command Helpers

Wrapping common commands in helper methods provides a cleaner API for your client.

Text
nextCodon(): void {
  this.sendCommand({
    id: `next-${Date.now()}`,
    type: "codon.next",
  });
}
 
skipCodon(): void {
  this.sendCommand({
    id: `skip-${Date.now()}`,
    type: "codon.skip",
  });
}
 
forceStop(reason?: string): void {
  this.sendCommand({
    id: `stop-${Date.now()}`,
    type: "codon.forceStop",
    data: reason ? { reason } : undefined,
  });
}
 
rollbackToLastSuccess(autoRestart: boolean = false): void {
  this.sendCommand({
    id: `rollback-${Date.now()}`,
    type: "rollback.toLastSuccess",
    data: { autoRestart },
  });
}

Event Filtering

Real applications rarely need every single event. These patterns help you find what matters.

By Category

Events fall into three main categories. Filtering by category is useful for different views or loggers.

Text
// Server state (execution lifecycle)
const serverStateTypes = new Set([
  "codon.started", "codon.completed", "state.snapshot", "server.idle",
  "token.usage", "info", "error", "checkpoint.list", "rollback.started",
  "rollback.progress", "rollback.codonCheckpoint", "rollback.completed",
  "rollback.rigCleanup", "state.transition",
]);
 
// Agentic backbone (agent and tool activity)
const agenticTypes = new Set([
  "assistant.action", "tool.result", "file.updated", "filetree.updated",
]);
 
// Sentinel events (background monitors)
const sentinelTypes = new Set([
  "sentinel.loaded", "sentinel.unloaded", "sentinel.error",
  "sentinel.output", "sentinel.triggered",
]);
 
function filterByCategory(events: ServerEvent[], category: "server" | "agentic" | "sentinel"): ServerEvent[] {
  const typeSet = {
    server: serverStateTypes,
    agentic: agenticTypes,
    sentinel: sentinelTypes,
  }[category];
 
  return events.filter((e) => typeSet.has(e.type));
}

By Codon

Isolate all events related to a specific codon execution.

Text
function getCodonEvents(events: ServerEvent[], codonId: string): ServerEvent[] {
  return events.filter((e) => {
    return "data" in e && e.data && "codonId" in e.data && e.data.codonId === codonId;
  });
}
 
function getCodonCost(events: ServerEvent[], codonId: string): number {
  const completed = events.find(
    (e) => e.type === "codon.completed" && e.data.codonId === codonId
  ) as CodonCompletedEvent | undefined;
 
  return completed?.data.cost ?? 0;
}

By Error Level

Find all errors, or only fatal ones, for alerting systems.

Text
function getErrors(events: ServerEvent[]): ErrorEvent[] {
  return events.filter((e): e is ErrorEvent => e.type === "error");
}
 
function hasFatalError(events: ServerEvent[]): boolean {
  return getErrors(events).some((e) => e.data.fatal);
}

Integration Patterns

Here are complete examples for common integration scenarios.

CI/CD Integration

Run a hank in your CI pipeline and fail the build on errors or excessive cost.

Text
async function runHankInCI(
  port: number,
  maxCost: number = 10.0
): Promise<{ success: boolean; cost: number; error?: string }> {
  const client = new HankweaveClient();
 
  try {
    await client.connectWithRetry(port);
    client.nextCodon(); // Start execution
 
    let totalCost = 0;
    while (true) {
      // Wait for any event, with a long timeout for long-running codons
      const event = await client.waitForEvent("*", 300000);
 
      if (event.type === "codon.completed") {
        totalCost += event.data.cost;
        if (!event.data.success) {
          return {
            success: false,
            cost: totalCost,
            error: event.data.failureReason?.message ?? "Codon failed",
          };
        }
      }
 
      if (event.type === "error" && event.data.fatal) {
        return {
          success: false,
          cost: totalCost,
          error: event.data.message,
        };
      }
 
      // Check for cost overruns
      if (totalCost > maxCost) {
        client.forceStop("Cost limit exceeded");
        return {
          success: false,
          cost: totalCost,
          error: `Cost limit exceeded: $${totalCost.toFixed(2)}`,
        };
      }
 
      // Exit condition: all codons are done
      if (event.type === "server.idle" && event.data.reason === "all-codons-completed") {
        break;
      }
    }
    return { success: true, cost: totalCost };
  } finally {
    await client.disconnect();
  }
}

Real-Time Dashboard

Stream events to a web dashboard by subscribing to the client's event stream.

Text
class DashboardBridge {
  private client: HankweaveClient;
  private subscribers: Set<(event: ServerEvent) => void> = new Set();
 
  constructor(private port: number) {
    this.client = new HankweaveClient();
  }
 
  async start(): Promise<void> {
    await this.client.connect(this.port, {
      mode: ClientMode.READONLY, // Dashboard only observes
    });
 
    // Stream all new events to subscribers
    this.client.onEvent((event) => {
      for (const subscriber of this.subscribers) {
        subscriber(event);
      }
    });
  }
 
  subscribe(callback: (event: ServerEvent) => void): () => void {
    this.subscribers.add(callback);
 
    // Immediately send historical events to the new subscriber
    for (const event of this.client.getEvents()) {
      callback(event);
    }
 
    // Return an unsubscribe function
    return () => this.subscribers.delete(callback);
  }
 
  // Get a snapshot of the current state
  getCurrentState() {
    const events = this.client.getEvents();
    // ... logic to derive state from events ...
  }
}

Sentinel Output Collector

Aggregate outputs from background sentinels for analysis.

Text
function collectSentinelOutputs(
  events: ServerEvent[]
): Map<string, { outputs: string[]; totalCost: number }> {
  const sentinels = new Map<string, { outputs: string[]; totalCost: number }>();
 
  for (const event of events) {
    if (event.type === "sentinel.output") {
      const { sentinelId, content, cost } = event.data;
 
      if (!sentinels.has(sentinelId)) {
        sentinels.set(sentinelId, { outputs: [], totalCost: 0 });
      }
 
      const sentinelData = sentinels.get(sentinelId)!;
      sentinelData.outputs.push(
        typeof content === "string" ? content : JSON.stringify(content)
      );
      sentinelData.totalCost += cost;
    }
  }
 
  return sentinels;
}

State File Access

For offline analysis after a run has completed, you can read state directly from disk instead of using the WebSocket API.

Text
import * as fs from "fs/promises";
import * as path from "path";
 
// Simplified state structure
interface HankweaveState {
  runs: Array<{
    codons: Array<{ finalCost?: number }>;
  }>;
}
 
async function getStateFromFile(executionDir: string): Promise<HankweaveState> {
  const statePath = path.join(executionDir, ".hankweave", "state.json");
  const content = await fs.readFile(statePath, "utf-8");
  return JSON.parse(content);
}
 
async function getTotalCostFromState(executionDir: string): Promise<number> {
  const state = await getStateFromFile(executionDir);
  let total = 0;
  for (const run of state.runs) {
    for (const codon of run.codons) {
      total += codon.finalCost ?? 0;
    }
  }
  return total;
}
💡

When to use file access vs. WebSocket: Use file access for post-run analysis, reports, and debugging. Use the WebSocket for real-time monitoring and control during execution.

TypeScript Types

Which path should you use?

  • Inside the Hankweave repo: Import directly from hankweave/server/schemas/event-schemas.
  • External projects: Define minimal types locally (shown below) or generate from the source Zod schemas.

Option 1: Inside the Hankweave Repository

If you are building within the Hankweave monorepo, import types directly.

Text
import type {
  ServerEvent,
  ClientCommand,
  CodonCompletedEvent,
  // ... other event types
} from "hankweave/server/schemas/event-schemas";
 
import { ClientMode } from "hankweave/server/types/types";

Option 2: External Projects

For projects outside the Hankweave repo, define minimal type stubs.

Text
// Minimal type definitions for external clients
enum ClientMode {
  READONLY = "readonly",
  READANDWRITE = "readandwrite",
}
 
interface ServerEvent {
  id: string;
  timestamp: string;
  type: string;
  data: any;
}
 
interface ClientCommand {
  id:string;
  type: string;
  data?: any;
}

Connection Lifecycle

Your client should track its connection state to behave predictably.

Connection state machine

  • Disconnected: No active WebSocket.
  • Connecting: WebSocket is opening.
  • Connected: WebSocket is open, but handshake is not yet sent.
  • HandshakePending: Handshake sent, awaiting response.
  • Ready: Handshake complete, ready to send commands.

Error Recovery

Production clients must handle failures gracefully. Servers restart and networks hiccup.

Automatic Reconnection

Implement exponential backoff to avoid spamming a server that is down.

Text
class ResilientClient {
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 10;
  private reconnectDelay = 1000;
 
  private async handleDisconnect(): Promise<void> {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.error("Max reconnection attempts reached. Giving up.");
      return;
    }
 
    this.reconnectAttempts++;
    // Exponential backoff
    const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
 
    console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})...`);
    await new Promise((resolve) => setTimeout(resolve, delay));
 
    try {
      await this.connect(); // Assumes connect() is defined on the class
      this.reconnectAttempts = 0; // Reset on success
    } catch (error) {
      await this.handleDisconnect(); // Retry
    }
  }
}

Syncing Missed Events

After reconnecting, you may have missed events. The handshake's sendPreviousEvents: true flag handles this for new connections. For an existing client that dropped and reconnected, you can use a history sync command to catch up.

Text
async syncAfterReconnect(): Promise<void> {
  // 1. Request a full event history sync
  this.sendCommand({
    id: `sync-${Date.now()}`,
    type: "history.sync",
  });
 
  // 2. Process batches of historical events until complete
  while (true) {
    const batch = (await this.waitForEvent("history.batch")) as HistoryBatchEvent;
 
    for (const event of batch.data.events) {
      // Add events to the buffer if they don't already exist
      if (!this.events.find((e) => e.id === event.id)) {
        this.events.push(event);
      }
    }
    if (!batch.data.hasMore) break;
  }
 
  // 3. Sort the buffer by timestamp to ensure correct order
  this.events.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
}

Related Pages

Next Steps

Start simple: connect in READONLY mode and log all incoming events. Once that works, add command-sending capabilities. The patterns here, derived from Hankweave's own test suite, provide a production-proven foundation for any integration you build.