Skip to content

Architecture

kayak-lab is built as a three-layer architecture: Core, Capabilities, and Projections.

Core Layer

The foundation of the platform. Handles event storage, session lifecycle, and agent execution.

EventStream

Immutable, append-only sequence of events. Events are strictly ordered by sequence number within a session. Different sessions are completely isolated.

typescript
const stream = new EventStream();

// Append an event
const event = stream.append({
  session_id: "abc-123",
  event_type: "session.created",
  payload: { state: "active" },
  metadata: { source: "test" },
});

// Read events for a session
const events = stream.getEvents("abc-123");

SessionManager

Manages session lifecycle with a state machine. All transitions emit typed events.

typescript
const manager = new SessionManager(eventStream);

// Create a session
const session = await manager.createSession({ description: "My task" });

// Pause and resume
manager.pauseSession(session.id);
manager.resumeSession(session.id);

// Complete
manager.completeSession(session.id);

AgentRuntime

The agent execution loop: input → model → tool cycle. Manages context windows and tool invocations.

typescript
const runtime = new AgentRuntime(eventStream, sessionManager, modelManager, toolRegistry);

// Or with the new structured tool calling protocol
const runtime = new AgentRuntime(eventStream, sessionManager, modelManager, legacyToolRegistry, newToolRegistry);

// Start and process input
await runtime.start();
const response = await runtime.processInput("Run ls -la");

Dual-protocol dispatch: AgentRuntime checks newToolRegistry first for structured tools (JSON Schema validated), then falls back to the legacy toolRegistry. This enables incremental migration from legacy tools to the structured protocol.

Tool Calling Module

Structured tool execution protocol with JSON Schema validation, tool registry, authoring, and self-improvement.

The module lives in src/tools/ and provides:

ComponentFilePurpose
ToolDefinitiontool-definition.tsJSON Schema parameter validation for tool invocations
ToolCallingEnginecalling-engine.tsExecutes tools with structured input/output and timeout handling
ToolRegistryregistry.tsEnable/disable lifecycle, discovery, and lookup of tools
ToolAuthoringauthoring.tsProposal/review/accept/reject flow for creating new tools
ToolSelfImprovementself-improvement.tsUsage tracking, suggestion generation, and auto-improvement
Typestypes.tsShared interfaces (ToolHandlerContext, ToolDefinition, etc.)
typescript
import { ToolCallingEngine, ToolRegistry } from "./src/tools/mod.ts";

// Register tools with definitions
const registry = new ToolRegistry();
registry.enable("shell", { description: "Execute shell commands", parameters: { /* JSON Schema */ } });

// Execute a tool call
const engine = new ToolCallingEngine(registry);
const result = await engine.invoke({
  tool_name: "shell",
  parameters: { command: "ls -la" },
  tool_call_id: "call-123",
});

Tool authoring flow: Propose → Review → Accept/Reject → Register. Tools go through a review lifecycle before being added to the registry. Self-improvement tracks usage patterns and suggests optimizations.

MCP Module

Model Context Protocol (MCP) integration for connecting to external MCP servers and exposing harness capabilities as an MCP server. The module provides client, server, registry, and search components with a transport abstraction layer.

ComponentFilePurpose
MCPClientclient.tsConnects to external MCP servers, discovers tools, invokes them
MCPServerserver.tsExposes harness tools (exposable only) to external MCP clients
MCPRegistryregistry.tsManages MCP tools from external servers with state control
MCPSearchsearch.tsSearches MCP tools by name, capability, or category
Transporttransport.tsStdio, HTTP, WebSocket transports with factory function
Eventsevents.tsMCP event types and type guards
Event Wiringevent-emitter.tsWires MCP events to the event stream
typescript
import { MCPClient, MCPServer, MCPRegistry, MCPSearch } from "./src/mcp/mod.ts";

// Connect to an external MCP server
const client = new MCPClient({
  name: "my-mcp-server",
  transport: { type: "stdio", command: "mcp-server", args: ["--port", "3000"] },
});
await client.connect();
const tools = await client.discover();

// Register discovered tools
const registry = new MCPRegistry();
for (const tool of tools) {
  registry.register({ tool, serverName: "my-mcp-server", enabled: true, capabilities: [] });
}

// Search for tools
const search = new MCPSearch(registry, new Map([["my-mcp-server", client]]));
const results = search.search({ name: "file" });

// Expose harness tools as MCP server
const server = new MCPServer({
  transport: { type: "http", url: "http://localhost:8080" },
  toolRegistry: {
    getExposableTools: () => toolRegistry.list().filter((t) => t.exposable),
    getTool: (name) => toolRegistry.get(name),
    invokeTool: (name, params) => toolRegistry.invoke(name, params),
  },
});
await server.start();

Transport abstraction: The createTransport factory creates the appropriate transport based on configuration. Each transport implements IMCPTransport with connect, disconnect, send, and notify methods.

Event integration: MCP operations emit events to the event stream via wireClientEvents, wireServerEvents, wireRegistryEvents, and wireSearchEvents helpers. Events include mcp.connected, mcp.disconnected, mcp.tool.invocation, mcp.tool.result, mcp.server.started, mcp.server.stopped, and mcp.error.

Memory

Persistent memory subsystem for agent learning and context retention. Stores and retrieves memories across sessions, enabling agents to accumulate knowledge over time.

Memory types:

TypePurposeRetention
EpisodicEvent logs — what happened in a sessionShort-term, event-sourced
SemanticLearned facts — knowledge acquired over timeLong-term, persistent
ProceduralLearned patterns — how to do thingsLong-term, persistent
WorkingActive context — current session focusSession-scoped, volatile

Key modules:

ComponentFilePurpose
MemoryProviderprovider.tsAbstraction layer over storage backends
MemoryStoragestorage.tsBackend storage interface
MemoryRetrievalretrieval.tsQuery and ranking of stored memories
MemorySearchsearch.tsFull-text search across memory stores
SharedMemoryshared.tsSub-agent context snapshots
MemoryConfigconfig.tsMemory configuration and backend selection
MemoryUpdateupdate.tsAtomic state transitions with event sourcing
MemoryEmitteremitter.tsMemory event emission for observability
typescript
import { MemoryProvider } from "./src/memory/provider.ts";

const memory = new MemoryProvider(config);
await memory.initialize();

// Store episodic memory
await memory.store({ type: "episodic", content: "User prefers dark mode" });

// Retrieve relevant memories
const results = await memory.search({ query: "user preferences", type: "semantic" });

Provider abstraction: The MemoryProvider decouples storage logic from consumers. Swap between in-memory, file-based, or database-backed storage without changing application code. SharedMemory enables sub-agents to share context snapshots without coupling to parent state.

EventStore

In-memory event persistence with snapshot support. Stores events per session with range queries and replay capabilities. Can be used as a fast/ephemeral store, or swapped with the persistent store for durability.

typescript
import { EventStore } from "./src/store/event-store.ts";

const store = new EventStore();

// Store an event
store.store(event);

// Read events
const events = store.getEvents("session-1");
const recent = store.getEventsInRange("session-1", 5, 10);

// Snapshots for fast replay
const snapshot = store.createSnapshot("session-1", { lastEventId: "abc" });

PersistentEventStore

File-based event persistence with JSONL append-only logs, snapshot persistence, and startup recovery. Durably writes events to disk and reconstructs in-memory state on initialization, enabling sessions to survive process restarts and crashes.

typescript
import { PersistentEventStore } from "./src/store/persistence.ts";

// Default: uses ./data/events/ directory
const store = new PersistentEventStore({ dataDir: "./data/events" });

// Custom directory
const store = new PersistentEventStore({ dataDir: "/var/lib/kayak/events" });

// Custom backend (e.g., SQLite in the future)
const store = new PersistentEventStore({
  dataDir: "./data/events",
  backend: new SQLitePersistenceBackend("./data/kayak.db"),
});

// Store events — written synchronously to JSONL on disk
store.store(event);

// Read events — served from in-memory cache (rebuilt from disk on startup)
const events = store.getEvents("session-1");
const recent = store.getEventsInRange("session-1", 5, 10);

// Snapshots — persisted to disk as JSON files
const snapshot = store.createSnapshot("session-1", { lastEventId: "abc" });

// Explicit flush (no-op for synchronous writes, available for future buffered backends)
store.flush();

File layout:

FilePurpose
<session_id>.jsonlAppend-only event log (one JSON object per line)
<session_id>.snapshot.jsonLatest snapshot for fast recovery

Recovery behavior:

  • On startup, PersistentEventStore scans the data directory, loads snapshots, and replays events after the snapshot point.
  • Corrupted lines are logged and skipped; valid events continue loading.
  • Empty or missing data directory → clean start with no sessions.

Pluggable backends:

The IPersistenceBackend interface allows swapping storage engines without changing callers:

typescript
interface IPersistenceBackend {
  write(sessionId: string, line: string): void;
  readLines(sessionId: string): string[];
  writeSnapshot(sessionId: string, data: Snapshot): void;
  readSnapshot(sessionId: string): Snapshot | undefined;
  listSessions(): string[];
  exists(sessionId: string): boolean;
}

The default FilePersistenceBackend uses synchronous Deno file I/O for guaranteed durability per write. Implement this interface for SQLite, PostgreSQL, or other backends.

Schema Registry

Event schema versioning and migration. Registers schema versions per event type and migrates events on read.

typescript
const registry = new SchemaRegistry();

// Register schema with migration
registry.register("session.created", 2, schemaV2, (event) => migrateV1toV2(event));

// EventStore uses registry for automatic migration
const store = new EventStore(undefined, registry);

Health System

Component health reporting with parallel checks and Kubernetes-compatible endpoints.

typescript
import { HealthRegistry, createHealthHandler } from "./src/core/health.ts";

const registry = new HealthRegistry();
registry.register("event-store", () => checkEventStore(store));
registry.register("capabilities", () => checkCapabilities(registry));

// Run all checks in parallel (1s timeout each)
const result = await registry.check();
// result.status: "healthy" | "degraded" | "unhealthy"

// HTTP endpoints
const handler = createHealthHandler(registry);
// GET /health  → full status (200/503)
// GET /ready   → readiness (200/503)
// GET /alive   → liveness (200)

Configuration Management

YAML-based config loading with env var overrides and secret masking.

typescript
import { loadConfig, validateConfig, maskSecrets } from "./src/core/config.ts";

// Load from config directory (precedence: env > file > defaults)
const config = await loadConfig("./config");

// Validate raw config
const result = validateConfig(rawObj);
if (!result.valid) {
  console.error(result.errors);
}

// Mask secrets in output
console.log(maskSecrets(config));
// { persistence: { dataDir: "/data" }, capabilities: { github: { token: "***" } } }

Hot-reload: ConfigWatcher watches the config file for changes with debounced file watching to prevent reload storms. On change, the new config is validated before replacing the active one — if validation fails, the previous config is retained and the error is logged, enabling automatic rollback to a known-good state.

Env var overrides: KAYAK_PERSISTENCE_DATA_DIRconfig.persistence.dataDir

Rate Limiting

Token bucket rate limiter for external API calls. Smooths burst traffic while enforcing average rate.

typescript
import { TokenBucket, RateLimiter } from "./src/core/rate-limiter.ts";

const bucket = new TokenBucket({
  capacity: 100,        // max burst
  refillRate: 10,       // tokens per interval
  refillIntervalMs: 1000,
});

const limiter = new RateLimiter(bucket);

// Wrap async function — throws on limit exceeded
const limitedFetch = limiter.wrap(fetch);
await limitedFetch("https://api.example.com");

// Or wait for tokens
const waitingFetch = limiter.wrapWithWait(fetch);
await waitingFetch("https://api.example.com"); // blocks until tokens available

Bounded Queue

Queue with configurable overflow policies for backpressure handling.

typescript
import { BoundedQueue } from "./src/core/bounded-queue.ts";

const queue = new BoundedQueue<Event>({
  maxSize: 1000,
  policy: "drop-oldest",  // or: drop-newest, block, reject
});

queue.push(event);
const next = queue.shift();

Overflow policies:

PolicyBehavior
drop-oldestRemove oldest item when full
drop-newestDiscard new item when full
blockWait until space available
rejectThrow error when full

Reliability Patterns

Circuit breaker, retry, and fallback for fault tolerance.

typescript
import { CircuitBreaker } from "./src/core/circuit-breaker.ts";
import { withRetry } from "./src/core/retry.ts";
import { executeWithFallback } from "./src/core/fallback.ts";

// Circuit breaker — opens after N failures
const breaker = new CircuitBreaker({ failureThreshold: 5, recoveryTimeMs: 30_000 });

// Retry with backoff
const result = await withRetry(fn, { maxRetries: 3, baseDelayMs: 100 });

// Fallback — try primary, fall back on failure
const result = await executeWithFallback(primaryFn, fallbackFn, breaker);

Error Taxonomy

Typed error hierarchy with error codes and retryability.

typescript
import { AppError, ValidationError, TimeoutError, RateLimitError } from "./src/core/errors.ts";

// All errors extend AppError
throw new ValidationError("Invalid input", { field: "name" });
throw new TimeoutError("Request timed out", { timeoutMs: 5000 });
throw new RateLimitError("Rate limited", { retryAfterMs: 60_000 });

// Error codes are unique strings
// AppError carries context, module, and operation

Capability Layer

Abstract interfaces for external systems. Capabilities are pluggable and independently testable.

CapabilityInterfaceImplementation
ShellIShellCapabilityReal — Deno.Command with safety constraints
SandboxISandboxRuntimeReal — Docker/gVisor with hardened flags
GitIGitCapabilityStubbed — simulated data
GitHubIGitHubCapabilityStubbed — simulated data
KubernetesIKubernetesCapabilityStubbed — simulated data

Capabilities follow a common pattern:

typescript
interface ICapability {
  readonly definition: CapabilityDefinition;
  initialize(context: CapabilityContext): Promise<void>;
  dispose(): Promise<void>;
}

interface CapabilityResult<T> {
  success: boolean;
  data?: T;
  error?: string;
}

Projection Layer

UI surfaces subscribe to the event stream and render events. Projections are independent — multiple can run simultaneously.

Projection Protocol

Subscription management with event filtering, pause/resume, and reconnection support.

typescript
const protocol = new ProjectionProtocol(eventStream);

// Subscribe to a session
const sub = protocol.subscribe("abc-123", (event) => {
  console.log(`[${event.event_type}]`, event.payload);
}, {
  filter: { event_types: ["tool.execution.started", "tool.execution.completed"] },
});

// Pause and resume
protocol.pause(sub.id);
protocol.resume(sub.id);

Terminal Projection

ANSI-colored event rendering for CLI surfaces.

typescript
const terminal = new TerminalProjection(protocol);
await terminal.start("abc-123");

WebSocket Projection

Real-time event delivery to connected clients via WebSocket. Supports subscription management, gap recovery, and backpressure.

typescript
import { WebSocketProjectionServer } from "./src/projection/websocket-server.ts";

const server = new WebSocketProjectionServer(eventStore, { port: 8080 });
await server.start();

// Clients connect via WebSocket, receive:
// - Welcome message with version and capabilities
// - Events matching their subscription filter
// - Gap recovery on reconnect

Heartbeat lifecycle: The WebSocket server maintains connection health via configurable heartbeat pings. heartbeatIntervalMs controls how often pings are sent; pongTimeoutMs determines how long to wait for a pong response before closing a stale connection.

Per-session event ordering: Events are inserted into each session's pendingEvents queue via binary search on sequence number, ensuring correct ordering even when events arrive out of order from concurrent sources.

Backpressure: Each client session uses a BoundedQueue for its send queue. When the queue fills, the configured overflow policy (drop-oldest, drop-newest, block, reject) applies, preventing slow consumers from blocking the event stream.

Client messages: subscribe, unsubscribe, reconnect, pongServer messages: welcome, event, error, ping

Review CLI

Standalone code review tool in review/. Runs custom checks and delegates to external CLI tools, producing prioritized findings. Not part of the agent runtime — executes directly via deno task review.

Check registry pattern: registry.ts scans review/checks/ and review/delegate/ at startup. Each file must export a default ReviewCheck object. No configuration or wiring required — add a file, it runs.

ComponentFilePurpose
Findingtypes.tsA single finding: title, body, priority (1–3), confidence, file path, line range
ReviewChecktypes.tsInterface: name, description, run(ctx)Finding[]
ReviewContexttypes.tsPre-computed file metadata shared across all checks
buildContextcontext.tsScans src/ once; builds file map, test pairing, dependency graph
loadChecksregistry.tsAuto-discovers custom checks from review/checks/
loadDelegatesregistry.tsAuto-discovers tool delegates from review/delegate/
formatFindingsformatter.tsGroups findings by file, renders with priority colors and summary
maincli.tsParses --only/--skip/--fail-on, runs preflight + checks, outputs results

CLI flags:

FlagEffect
--only <name>Run only the named check(s)
--skip <name>Skip the named check(s) or preflight
--fail-on <priority>Exit 1 if any finding has priority ≤ threshold (CI gate)

Custom check example:

typescript
import type { Finding, ReviewCheck, ReviewContext } from "../types.ts";

const check: ReviewCheck = {
  name: "my-check",
  description: "What this check does",
  async run(ctx: ReviewContext): Promise<Finding[]> {
    const findings: Finding[] = [];
    for (const [, entry] of ctx.files) {
      // scan and produce findings
    }
    return findings;
  },
};

export default check;

Delegate example (wraps an external CLI tool):

typescript
import type { Finding, ReviewCheck } from "../types.ts";

const check: ReviewCheck = {
  name: "my-tool",
  description: "Runs an external tool",
  async run(): Promise<Finding[]> {
    const cmd = new Deno.Command("my-tool", { args: [...], stdout: "piped" });
    const output = await cmd.output();
    // parse output into Finding[]
    return findings;
  },
};

export default check;

Data Flow

Design Principles

  1. Events are the source of truth — All state is derivable from events. No hidden mutable state.
  2. UI independence — Agent runtime never knows about UI surfaces. Projections are disposable.
  3. Provider independence — Model providers are abstracted. Swap without code changes.
  4. Capability independence — External systems are abstracted. Test with mocks.
  5. Session isolation — Events from different sessions never mix. Concurrent sessions are safe.
  6. Recovery by replay — Any session state can be reconstructed from its event stream.

Released under the ISC License.