npm package: @kadi.build/core
Description: A lean, readable SDK for building KADI agents.
Author: Corey Clark
Latest version: 0.15.6

# kadi-core

TypeScript SDK for building KADI agents. Register tools, connect to brokers, load abilities, publish events, manage agent configuration, and run background processes.

kadi-core lets you:
- **Expose functions** as callable tools over the network
- **Discover and invoke** remote services without hardcoding URLs
- **Communicate via events** across your service mesh
- **Load configuration** from `config.yml` with project → global fallback and env var overrides
- **Manage agent.json** configuration across projects, abilities, and global scope
- **Run background processes** with headless, piped, and JSON-RPC bridge modes
- **Install as an ability** — use kadi-core itself as a dependency in any agent

## Install

```bash
npm install @kadi.build/core
```

**Requirements:**
- Node.js 18+
- TypeScript 5.0+ (recommended for full type inference)
- ESM only (`"type": "module"` in your package.json)

## Quick Start

Create a simple calculator agent:

```typescript
// calculator.ts
import { KadiClient, z } from '@kadi.build/core';

const client = new KadiClient({
  name: 'calculator',
  version: '1.0.0',
});

client.registerTool({
  name: 'add',
  description: 'Add two numbers',
  input: z.object({ a: z.number(), b: z.number() }),
}, async ({ a, b }) => ({ result: a + b }));

// Start listening for requests over stdio
await client.serve('stdio');
```

Now another agent can load and use it:

```typescript
import { KadiClient } from '@kadi/core';

const caller = new KadiClient({ name: 'caller', version: '1.0.0' });

// Load the calculator ability (defaults to native transport)
const calc = await caller.load('calculator');

// Invoke a tool
const result = await calc.invoke('add', { a: 5, b: 3 });
console.log(result); // { result: 8 }
```

---

## Table of Contents

- [Core Concepts](#core-concepts)
- [Registering Tools](#registering-tools)
- [Connecting to Brokers](#connecting-to-brokers)
- [Loading Abilities](#loading-abilities)
- [Events from Abilities](#events-from-abilities)
- [Broker Events (Pub/Sub)](#broker-events-pubsub)
- [Serving as an Ability](#serving-as-an-ability)
- [Agent.json Management](#agentjson-management)
- [Process Manager](#process-manager)
- [Encryption (Crypto)](#encryption-crypto)
- [Installing as an Ability](#installing-as-an-ability)
- [Configuration (loadConfig)](#configuration-loadconfig)
- [ReActLoop (Agent Loop)](#reactloop-agent-loop)
- [Error Handling](#error-handling)
- [API Reference](#api-reference)
- [Troubleshooting](#troubleshooting)

---

## Core Concepts

| Concept | Plain English |
|---------|---------------|
| **Tool** | A function other services can call over the network (like an API endpoint) |
| **Agent** | Your service. One codebase = one agent. Agents expose tools and call other agents' tools. |
| **Broker** | The router/registry. Agents register with brokers so they can find each other (like DNS + API gateway combined) |
| **Ability** | A tool provider you connect to. Could be in-process, a subprocess, or a remote service. |
| **Event** | Fire-and-forget notification. Publish events, other agents can subscribe. |

---

## Registering Tools

Tools are functions you expose for other agents to call.

```typescript
import { KadiClient, z } from '@kadi.build/core';

const client = new KadiClient({ name: 'weather-service' });

// Define input schema with Zod
const weatherInput = z.object({
  city: z.string().describe('City name'),
  units: z.enum(['celsius', 'fahrenheit']).default('celsius'),
});

// Register tool
client.registerTool({
  name: 'getWeather',
  description: 'Get current weather for a city',
  input: weatherInput,
}, async ({ city, units }) => {
  const temp = await fetchTemperature(city);
  return { city, temperature: temp, units };
});
```

**Why Zod?** Type-safe schemas, 70% less code than JSON Schema, automatic TypeScript inference.

### Targeting Specific Brokers

```typescript
// Register tool only on specific brokers
client.registerTool(definition, handler, { brokers: ['internal'] });
```

### Targeting Specific Networks

By default, a tool is visible on **all networks** the agent has joined. You can restrict a tool to specific networks using the `networks` option — the tool will only be discoverable and invocable by agents on those networks.

```typescript
const client = new KadiClient({
  name: 'multi-network-agent',
  brokers: { default: 'ws://localhost:8080/kadi' },
  networks: ['global', 'internal', 'gpu-cluster'],
});

// Visible on ALL agent networks (global, internal, gpu-cluster)
client.registerTool({
  name: 'echo',
  description: 'Echo back the input',
  input: z.object({ message: z.string() }),
}, async ({ message }) => ({ echo: message }));

// Visible ONLY on gpu-cluster
client.registerTool({
  name: 'gpu-inference',
  description: 'Run ML inference on GPU',
  input: z.object({ model: z.string(), prompt: z.string() }),
}, async ({ model, prompt }) => {
  return { result: await runInference(model, prompt) };
}, { networks: ['gpu-cluster'] });

// Visible on internal AND global only (not gpu-cluster)
client.registerTool({
  name: 'audit-log',
  description: 'Retrieve audit logs',
  input: z.object({ limit: z.number() }),
}, async ({ limit }) => {
  return { logs: await getAuditLogs(limit) };
}, { networks: ['internal', 'global'] });
```

Per-tool networks must be a **subset** of the client's `networks`. Attempting to scope a tool to a network the agent hasn't joined throws an `INVALID_CONFIG` error:

```typescript
// Throws KadiError — 'finance' is not in client networks
client.registerTool(definition, handler, { networks: ['finance'] });
```

You can combine `brokers` and `networks` to control both which brokers a tool registers with and which networks it's visible on:

```typescript
client.registerTool(definition, handler, {
  brokers: ['production'],
  networks: ['internal'],
});
```

---

## Connecting to Brokers

Brokers let agents discover and call each other without knowing URLs.

```typescript
const client = new KadiClient({
  name: 'my-agent',
  brokers: {
    default: 'ws://localhost:8080/kadi',
    production: 'wss://broker.example.com/kadi',
  },
  defaultBroker: 'default',
  networks: ['global'],  // Which networks to join
});
```

### Broker Config: String vs Object Form

Broker entries accept either a **URL string** (shorthand) or a **full object** with network scoping:

```typescript
const client = new KadiClient({
  name: 'my-agent',
  brokers: {
    // String form — shorthand, defaults to networks: ['global']
    default: 'ws://localhost:8080/kadi',

    // Object form — explicit network scoping
    production: {
      url: 'wss://broker.example.com/kadi',
      networks: ['global', 'internal'],
    },
  },
});
```

String-form entries are normalized at construction time to `{ url, networks: ['global'] }`. Both forms can be mixed freely.

```typescript

// Connect (uses Ed25519 authentication)
await client.connect();

// Invoke a tool - broker routes to any available provider
const result = await client.invokeRemote('add', { a: 5, b: 3 });

// Disconnect when done
await client.disconnect();
```

### Reconnection

Auto-reconnects with exponential backoff (1s → 2s → 4s → ... → 30s max) with jitter:

```typescript
const client = new KadiClient({
  name: 'resilient-agent',
  brokers: { default: 'ws://localhost:8080/kadi' },
  autoReconnect: true,        // Default: true
  maxReconnectDelay: 30000,   // Cap at 30 seconds
});
```

**Tool snapshot on reconnection:** When an agent first registers with a broker, kadi-core captures a snapshot of the tools sent during that initial registration. On every subsequent reconnection, the same snapshot is replayed — tools that were registered locally after the initial connection (e.g. via `loadNative`) are **not** leaked to the broker.

This means abilities that use the native-mode pattern (connect first, then register tools locally) will correctly announce zero tools to the broker on reconnection, just as they did on the initial connect.

**Event subscriptions on reconnection:** All broker event subscriptions (registered via `subscribe()`) are automatically re-established on reconnect. Your event handlers continue working transparently — no manual resubscription needed.

If you dynamically register new tools after the initial connection and want the broker to know about them, call `refreshBrokerTools()`:

```typescript
// Register a new tool after the initial connection
client.registerTool({
  name: 'new-dynamic-tool',
  description: 'Added at runtime',
  input: z.object({ data: z.string() }),
}, async ({ data }) => ({ result: data }));

// Recapture the tool snapshot and re-announce to the broker
await client.refreshBrokerTools();

// Or target a specific broker
await client.refreshBrokerTools('production');
```

### Lifecycle Hooks

Register callbacks for broker connection lifecycle events:

```typescript
// Run cleanup logic when a broker disconnects
client.onDisconnect((brokerName) => {
  console.log(`Lost connection to ${brokerName}`);
});

// Run logic after a broker successfully reconnects
client.onReconnect((brokerName) => {
  console.log(`Reconnected to ${brokerName}`);
  // Good place to refresh state, re-sync data, etc.
});
```

Both hooks fire with the broker name and support async callbacks. Multiple hooks can be registered and will run in order. Errors in hooks are caught and logged — they won't break the reconnection flow.

---

## Loading Abilities

Use `load()` to connect to any ability. It picks the right transport based on the `transport` option (defaults to `'native'`):

```typescript
// Native (default) — runs in your process
const calc = await client.load('calculator');

// Stdio — spawns a child process (any language)
const processor = await client.load('image-processor', { transport: 'stdio' });

// Broker — connects to a remote service via WebSocket
const gpu = await client.load('gpu-service', { transport: 'broker' });
```

Once loaded, every ability exposes the same interface — call tools with `invoke()`:

```typescript
const result = await calc.invoke('add', { a: 5, b: 3 });
console.log(result); // { result: 8 }

await calc.disconnect();
```

### Transport Options

| Transport | Use When | How It Works |
|-----------|----------|--------------|
| `'native'` (default) | Ability is a local TypeScript/JavaScript module | Runs in your process |
| `'stdio'` | Ability runs as a separate process (any language) | JSON-RPC over stdin/stdout |
| `'broker'` | Ability is a remote service | WebSocket via broker |

### Native Transport (default)

```typescript
// Resolves path from agent-lock.json
const calc = await client.load('calculator');

// Or specify path directly
const calc = await client.load('calculator', { path: './abilities/calc' });

const result = await calc.invoke('add', { a: 5, b: 3 });
```

### Stdio Transport

By default, launches the ability using `scripts.start` from the ability's `agent.json`:

```typescript
const processor = await client.load('image-processor', { transport: 'stdio' });

const result = await processor.invoke('resize', { width: 800 });

// Cleanup (terminates child process)
await processor.disconnect();
```

You can specify a different script to launch:

```typescript
const processor = await client.load('image-processor', {
  transport: 'stdio',
  script: 'dev',
});
```

Or bypass `agent.json` entirely with an explicit command:

```typescript
const processor = await client.load('processor', {
  transport: 'stdio',
  command: 'python3',
  args: ['main.py'],
  cwd: '/path/to/working/dir', // optional working directory
});
```

### Broker Transport

```typescript
// Must be connected to broker first
await client.connect();

const gpu = await client.load('gpu-service', {
  transport: 'broker',
  networks: ['gpu-cluster'],
});

const result = await gpu.invoke('inference', { model: 'llama3' });
await gpu.disconnect();
```

### Transport-Specific Methods (Advanced)

If you need direct access to a specific transport, the underlying methods are still available:

| Method | Equivalent `load()` Call |
|--------|--------------------------|
| `loadNative(name, opts)` | `load(name, { ...opts })` |
| `loadStdio(name, opts)` | `load(name, { transport: 'stdio', ...opts })` |
| `loadBroker(name, opts)` | `load(name, { transport: 'broker', ...opts })` |

---

## Events from Abilities

Abilities can emit real-time events. Subscribe with `on()`, unsubscribe with `off()`.

```typescript
const watcher = await client.load('file-watcher', { transport: 'stdio' });

// Define handlers (need references to unsubscribe later)
const onFileChanged = (data) => {
  console.log('File changed:', data.path, data.action);
};

const onError = (data) => {
  console.error('Watch error:', data.message);
};

// Subscribe to events
watcher.on('file.changed', onFileChanged);
watcher.on('file.error', onError);

// Later, unsubscribe
watcher.off('file.changed', onFileChanged);

// Cleanup (terminates child process)
await watcher.disconnect();
```

### Emitting Events (from inside an ability)

If you're building an ability that emits events:

```typescript
// Inside your ability code
client.emit('job.progress', { percent: 50, message: 'Halfway done' });
client.emit('file.changed', { path: '/tmp/foo.txt', action: 'modified' });
```

---

## Broker Events (Pub/Sub)

When connected to a broker, agents can publish and subscribe to events across the network.

### Subscribing

```typescript
await client.connect();

// Subscribe to patterns (RabbitMQ topic exchange style)
client.subscribe('user.*', (event) => {
  console.log(`Channel: ${event.channel}`);   // 'user.login'
  console.log(`Data:`, event.data);           // { userId: '123' }
  console.log(`Network: ${event.networkId}`); // 'global'
  console.log(`Source: ${event.source}`);     // Publisher's session ID
});
```

**Pattern matching:**
- `user.*` → matches `user.login`, `user.logout` (exactly one word after dot)
- `user.#` → matches `user`, `user.login`, `user.profile.update` (zero or more words)
- `order.new` → matches exactly `order.new`

### Publishing

```typescript
// Publish to default network
await client.publish('user.login', { userId: '123', timestamp: Date.now() });

// Publish to specific network
await client.publish('order.created', orderData, { network: 'internal' });
```

### Unsubscribing

```typescript
const handler = (event) => console.log(event);
client.subscribe('user.*', handler);

// Later, remove this specific handler
client.unsubscribe('user.*', handler);
```

---

## Serving as an Ability

To make your agent callable by others:

### Stdio Mode (for loadStdio)

```typescript
const client = new KadiClient({ name: 'my-ability' });

client.registerTool({
  name: 'process',
  description: 'Process data',
  input: z.object({ data: z.string() }),
}, async ({ data }) => {
  return { processed: data.toUpperCase() };
});

// Serve over stdio - blocks until SIGTERM/SIGINT
await client.serve('stdio');
```

### Broker Mode (for loadBroker)

```typescript
const client = new KadiClient({
  name: 'my-service',
  brokers: { default: 'ws://localhost:8080/kadi' },
});

client.registerTool({ /* ... */ }, handler);

// Connect to broker and wait for requests
await client.serve('broker');
```

---

## Agent.json Management

The `AgentJsonManager` provides unified read/write/delete access to `agent.json` files across three scopes:

| Scope | Location | Use Case |
|-------|----------|----------|
| **Project** | `./agent.json` | Your agent's own configuration |
| **Ability** | `./abilities/<name>@<version>/agent.json` | Installed ability configuration |
| **Home** | `~/.kadi/agent.json` | Global CLI / user-level configuration |

### Via KadiClient

The client exposes a lazy `agentJson` property — no extra instantiation needed:

```typescript
const client = new KadiClient({ name: 'my-agent' });

// Read the full project config
const config = await client.agentJson.readProject();

// Read a nested field with dot-path notation
const target = await client.agentJson.readProject('deploy.local.target');

// Write a nested field (deep-merges into existing data)
await client.agentJson.writeProject('deploy.staging', {
  target: 'akash',
  replicas: 2,
});

// Delete a field
await client.agentJson.deleteProject('deploy.staging');
```

### Standalone Usage

You can also use `AgentJsonManager` directly without a client:

```typescript
import { AgentJsonManager } from '@kadi.build/core';

const ajm = new AgentJsonManager({
  projectRoot: '/path/to/my-agent',   // Optional, auto-detects via agent-lock.json
  kadiHome: '~/.kadi',                // Optional, defaults to ~/.kadi
  createOnWrite: true,                // Create file if missing on write (default: true)
});

const config = await ajm.readProject();
```

### Reading Ability Config

```typescript
// Read full config for an installed ability (resolves highest semver by default)
const calcConfig = await client.agentJson.readAbility('calculator');

// Read a specific field
const scripts = await client.agentJson.readAbility('calculator', {
  field: 'scripts.start',
});

// Pin to a specific version (useful when multiple versions are installed)
const old = await client.agentJson.readAbility('calculator', {
  version: '1.0.0',
});
```

### Global Home Config

```typescript
// Read global KADI configuration
const homeConfig = await client.agentJson.readHome();
const defaultBroker = await client.agentJson.readHome('brokers.default');

// Write to global config
await client.agentJson.writeHome('brokers.staging', 'wss://staging.example.com/kadi');
```

### Discovery

```typescript
// List all installed abilities (reads agent-lock.json)
const abilities = await client.agentJson.listAbilities();
// [{ name: 'calculator', version: '1.0.0', path: '/abs/path/abilities/calculator@1.0.0' }, ...]

// Check if an ability is installed
const has = await client.agentJson.hasAbility('calculator');

// Get all installed versions of an ability
const versions = await client.agentJson.getAbilityVersions('calculator');
// ['1.0.0', '2.0.0']

// Get resolved paths for all three scopes
const paths = await client.agentJson.getPaths();
// { project: '/abs/path/agent.json', home: '/home/user/.kadi/agent.json' }
```

---

## Process Manager

The `ProcessManager` spawns and supervises background child processes in three modes:

| Mode | Communication | Use Case |
|------|--------------|----------|
| **headless** | None | Fire-and-forget tasks (docker build, git clone) |
| **piped** | stdout/stderr streaming | Tasks where you need live output (deploys, logs) |
| **bridge** | JSON-RPC 2.0 over stdio | Interactive workers (inference engines, language servers) |

### Via KadiClient

```typescript
const client = new KadiClient({ name: 'my-agent' });

// Headless — fire and forget
const build = await client.processes.spawn('build', {
  command: 'docker',
  args: ['build', '-t', 'myapp', '.'],
  mode: 'headless',
});

// Check status later
const status = client.processes.getStatus('build');
console.log(status); // 'running' | 'exited' | 'killed' | 'errored'
```

### Piped Mode — Live Output

```typescript
const deploy = await client.processes.spawn('deploy', {
  command: 'kadi',
  args: ['deploy', '--target', 'akash'],
  mode: 'piped',
  cwd: '/path/to/project',
  env: { KADI_ENV: 'production' },
});

// Stream stdout/stderr in real time
deploy.on('stdout', (chunk) => process.stdout.write(chunk));
deploy.on('stderr', (chunk) => process.stderr.write(chunk));
deploy.on('exit', ({ exitCode }) => console.log('Deploy exited:', exitCode));

// Get buffered output later
const output = deploy.getOutput();
console.log(output.stdout); // Full stdout string
console.log(output.stderr); // Full stderr string
```

### Bridge Mode — JSON-RPC Workers

Bridge mode wraps the child process in the same Content-Length framed JSON-RPC protocol used by stdio transports. This lets you send requests to the child and receive responses:

```typescript
const worker = await client.processes.spawn('inference', {
  command: 'python3',
  args: ['worker.py'],
  mode: 'bridge',
});

// Send a JSON-RPC request and await the response
const result = await worker.request('run-inference', {
  model: 'llama3',
  prompt: 'Explain kadi in one sentence',
});
console.log(result);

// Fire-and-forget notification (no response expected)
worker.notify('update-config', { temperature: 0.7 });

// Listen for notifications from the worker
worker.on('notification', ({ method, params }) => {
  console.log(`Worker says: ${method}`, params);
});
```

### Lifecycle Management

```typescript
// List all managed processes
const all = client.processes.list();
const running = client.processes.list({ mode: 'piped' }); // Filter by mode

// Get a handle to an existing process
const proc = client.processes.get('build');

// Get detailed info
const info = proc.getInfo();
// { id: 'build', pid: 12345, mode: 'piped', state: 'running', startedAt: ... }

// Kill a single process (SIGTERM → grace period → SIGKILL)
await proc.kill();

// Kill all managed processes
await client.processes.killAll();

// Wait for a process to exit
const exitInfo = await proc.waitForExit();
console.log(exitInfo.exitCode, exitInfo.signal);

// Clean up exited/errored entries
client.processes.prune();

// Graceful shutdown — kills all and cleans up
await client.processes.shutdown();
```

### Timeout Auto-Kill

Processes can have an automatic timeout. When the timeout fires, the process is killed:

```typescript
const task = await client.processes.spawn('migration', {
  command: 'node',
  args: ['migrate.js'],
  mode: 'piped',
  timeout: 60000, // Kill after 60 seconds if still running
});
```

### Standalone Usage

```typescript
import { ProcessManager } from '@kadi.build/core';

const pm = new ProcessManager();

const worker = await pm.spawn('worker', {
  command: 'python3',
  args: ['worker.py'],
  mode: 'bridge',
});

const result = await worker.request('echo', { message: 'hello' });
await pm.shutdown();
```

---

## Encryption (Crypto)

`client.crypto` provides NaCl sealed-box encryption for secure inter-agent communication. Sealed boxes let anyone encrypt a message using the recipient's public key — only the recipient can decrypt it. The sender remains anonymous (use API tokens or signatures for authentication).

Keys are derived automatically from the agent's Ed25519 identity — no extra key management required.

### Encrypting for Another Agent

```typescript
// Agent A encrypts secrets for Agent B
const encrypted = agentA.crypto.encryptFor(
  JSON.stringify({ API_KEY: 'sk-123', DB_PASS: 'hunter2' }),
  agentB.publicKey  // Ed25519 identity key (auto-converted to X25519)
);
```

### Decrypting

```typescript
// Agent B decrypts
const plaintext = agentB.crypto.decrypt(encrypted);
const secrets = JSON.parse(plaintext);
```

### Getting Your Encryption Public Key

```typescript
// X25519 encryption key (base64) — share this with senders
const encryptionKey = client.crypto.publicKey;

// Ed25519 identity key — the standard client.publicKey
const identityKey = client.crypto.identityPublicKey;
```

### Key Format Auto-Detection

`encryptFor()` accepts both key formats:

- **Ed25519 SPKI DER base64** (44 bytes decoded) — the standard `client.publicKey` format, auto-converted to X25519
- **Raw X25519 base64** (32 bytes decoded) — used directly, no conversion needed

```typescript
// Both work:
client.crypto.encryptFor('secret', otherAgent.publicKey);        // Ed25519 → auto-converted
client.crypto.encryptFor('secret', otherAgent.crypto.publicKey); // X25519 → used directly
```

---

## Installing as an Ability

kadi-core v0.10.0 ships its own `agent.json` with `type: "ability"`, so it can be installed into any agent project using the standard `kadi install` workflow:

```bash
kadi install kadi-core
```

After installation, a `postinstall` script creates a symlink at `node_modules/@kadi.build/core` pointing to the installed ability directory. This means your code can use the standard import path regardless of where the ability lives on disk:

```typescript
import { KadiClient, z } from '@kadi.build/core';
```

This is particularly useful for:
- **Python agents** (via `kadi-core-py`) that need a reference to kadi-core's protocol definitions
- **Polyglot projects** that install abilities via `kadi install` rather than `npm install`
- **Deployed agents** where abilities are resolved from `agent-lock.json`

---

## Configuration (loadConfig)

Load settings from `config.yml` with a standardized 3-tier resolution:

1. **Environment variables** — `PREFIX_KEY` (highest priority)
2. **Project config.yml** — walk up from CWD to find the nearest `config.yml`
3. **Global `~/.kadi/config.yml`** — shared KADI infrastructure settings
4. **Built-in defaults** — hardcoded fallbacks (lowest priority)

Secrets (API keys, tokens) should **never** go in `config.yml`. Use `kadi secret` for credentials.

### Basic Usage

```typescript
import { loadConfig } from '@kadi.build/core';

const { config, configPath, source } = loadConfig({
  section: 'tunnel',
  envPrefix: 'KADI_TUNNEL',
  defaults: {
    server_addr: 'kadi.build:7835',
    tunnel_domain: 'tunnels.kadi.build',
  },
});

console.log(config.server_addr);  // from config.yml or default
console.log(configPath);           // '/path/to/config.yml' or null
console.log(source);               // 'project' | 'global' | 'default'
```

With a `config.yml` like:

```yaml
tunnel:
  server_addr: kadi.build:7835
  tunnel_domain: tunnels.kadi.build

memory:
  database: kadi_memory
  embedding_model: text-embedding-3-small
```

### Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `section` | `string` | *(required)* | Top-level YAML key to extract |
| `startDir` | `string` | `process.cwd()` | Directory to start the upward walk |
| `filename` | `string` | `'config.yml'` | Config filename to search for |
| `envPrefix` | `string` | — | Env var prefix for automatic overrides |
| `defaults` | `Record<string, unknown>` | `{}` | Built-in defaults (lowest priority) |

### Environment Variable Overrides

When `envPrefix` is set, loadConfig scans `process.env` for matching keys:

```
envPrefix: 'MEMORY'

MEMORY_DATABASE=my_db        →  config.database = 'my_db'
MEMORY_EMBEDDING_MODEL=ada   →  config.embedding_model = 'ada'
```

Values are coerced automatically: `"true"` → `true`, `"42"` → `42`, `"3.14"` → `3.14`.

### File Discovery Helpers

```typescript
import { findConfigFile, findGlobalConfigFile } from '@kadi.build/core';

// Walk up from CWD to find config.yml
const projectConfig = findConfigFile();

// Walk up from a specific directory
const agentConfig = findConfigFile('config.yml', '/path/to/agent');

// Check ~/.kadi/config.yml
const globalConfig = findGlobalConfigFile();
```

### Result Type

```typescript
import type { ConfigResult, ConfigSource, LoadConfigOptions } from '@kadi.build/core';

interface ConfigResult<T = Record<string, unknown>> {
  config: T;              // Merged config object
  configPath: string | null;  // Path to config.yml used, or null
  source: ConfigSource;   // 'project' | 'global' | 'default'
}
```

> **Note:** `loadConfig` requires `js-yaml` at runtime. It is lazy-imported — if your project doesn't use `loadConfig`, you don't need `js-yaml` installed.

---

## ReActLoop (Agent Loop)

`ReActLoop` is a lightweight agent execution primitive that implements the Reason + Act loop pattern. It handles tool calling, turn management, compaction, and events — nothing more.

```typescript
import { KadiClient, ReActLoop } from '@kadi.build/core';

const client = new KadiClient({
  name: 'my-agent',
  brokers: { default: 'ws://localhost:8080/kadi' },
});
await client.connect();

const loop = new ReActLoop({
  model: { broker: 'default', ability: 'model-provider', tool: 'chat' },
  tools: {
    broker: 'default',
    customTools: [
      {
        definition: { name: 'get-weather', description: 'Get weather for a city', inputSchema: { type: 'object', properties: { city: { type: 'string' } } } },
        handler: async (params) => ({ temp: 72, unit: 'F', city: params.city }),
      },
    ],
  },
  maxTurns: 10,
  timeout: '5m',
}, client);

const result = await loop.run('What is the weather in Austin?');
console.log(result.output);   // Final model response
console.log(result.turns);    // Number of turns taken
console.log(result.stopReason); // 'model_done' | 'max_turns' | 'timeout' | ...
```

### Configuration

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `model` | `ModelConfig` | required | How to call the LLM (broker routing or custom `modelCall`) |
| `tools` | `ToolsConfig` | required | Tool discovery: broker name, custom tools, and/or a tool router |
| `maxTurns` | `number` | `25` | Maximum loop iterations |
| `timeout` | `string` | `'10m'` | Loop timeout (`'30s'`, `'5m'`, `'1h'`) |
| `systemPrompt` | `string` | `undefined` | Prepended as a system message |
| `compaction` | `object` | `{ threshold: 80000, model: ... }` | Context compaction settings. Set `{ mode: 'off' }` to disable |
| `hooks` | `ReActHooks` | `{}` | Lifecycle callbacks (see below) |
| `events` | `EventsConfig` | `{}` | Event emission settings (broker name, prefix) |

### Hooks

```typescript
const loop = new ReActLoop({
  // ...model and tools config...
  hooks: {
    beforeTurn: async (turnInfo) => {
      console.log(`Starting turn ${turnInfo.turn}`);
    },
    afterTurn: async (turnResult) => {
      console.log(`Turn ${turnResult.turn} used ${turnResult.toolCalls.length} tools`);
    },
    onToolCall: async (toolName, params) => {
      // Modify params before execution
      return { ...params, timestamp: Date.now() };
    },
    onError: async (error, context) => {
      // Return 'retry', 'skip', or 'abort'
      return error.message.includes('rate limit') ? 'retry' : 'abort';
    },
  },
}, client);
```

### Steering & Cancellation

Use `start()` instead of `run()` for a `LoopHandle` that supports mid-loop steering and cancellation:

```typescript
const handle = loop.start('Summarize the latest news');

// Inject a message mid-loop
handle.steer('Focus on technology news only');

// Cancel the loop
handle.cancel();

// Await the result
const result = await handle.completion;
```

### Events

When `events.broker` is set, ReActLoop emits events to the broker:

| Event | Payload | When |
|-------|---------|------|
| `kadi.agent.turn` | `{ turn, toolCalls }` | After each turn |
| `kadi.agent.tool` | `{ tool, params, result }` | After each tool execution |
| `kadi.agent.done` | `{ turns, stopReason, output }` | Loop completed |
| `kadi.agent.error` | `{ error, turn }` | Error occurred |

### Stop Reasons

| Reason | Description |
|--------|-------------|
| `model_done` | Model responded without requesting any tool calls |
| `max_turns` | Hit the `maxTurns` limit |
| `timeout` | Hit the `timeout` limit |
| `should_continue` | `shouldContinue` callback returned `false` |
| `cancelled` | `handle.cancel()` was called |
| `error` | Unrecoverable error (or `onError` returned `'abort'`) |

---

## Error Handling

All errors are `KadiError` with structured codes and context:

```typescript
import { KadiError } from '@kadi.build/core';

try {
  await client.invokeRemote('unknown-tool', {});
} catch (error) {
  if (KadiError.isKadiError(error)) {
    console.log(error.code);      // 'TOOL_NOT_FOUND'
    console.log(error.message);   // Human-readable message
    console.log(error.context);   // { toolName: '...', hint: '...' }
  }
}
```

### Error Codes

| Code | When It Happens |
|------|-----------------|
| `TOOL_NOT_FOUND` | `invoke()` or `invokeRemote()` with unknown tool name |
| `BROKER_NOT_CONNECTED` | Any broker operation before calling `connect()` |
| `ABILITY_NOT_FOUND` | `loadNative/loadStdio` when ability not in agent-lock.json |
| `ABILITY_LOAD_FAILED` | Ability exists but failed to start or initialize |
| `TOOL_INVOCATION_FAILED` | Tool threw an error during execution |
| `BROKER_TIMEOUT` | Request to broker timed out |
| `INVALID_CONFIG` | Configuration error (missing required fields, etc.) |
| `LOCKFILE_NOT_FOUND` | `loadNative/loadStdio` when agent-lock.json doesn't exist |
| `AGENT_JSON_NOT_FOUND` | `readProject/readAbility/readHome` when agent.json doesn't exist |
| `AGENT_JSON_PARSE_ERROR` | agent.json exists but contains invalid JSON |
| `AGENT_JSON_WRITE_ERROR` | Failed to write agent.json (permissions, disk, etc.) |
| `AGENT_JSON_FIELD_NOT_FOUND` | Dot-path field doesn't exist in agent.json |
| `AGENT_JSON_VERSION_AMBIGUOUS` | Multiple versions installed and no version specified |
| `PROCESS_ALREADY_EXISTS` | `spawn()` with an ID that's already in use |
| `PROCESS_NOT_FOUND` | `get()` / `getStatus()` with an unknown process ID |
| `PROCESS_NOT_RUNNING` | `request()` / `notify()` on an exited process |
| `PROCESS_BRIDGE_ERROR` | JSON-RPC communication error in bridge mode |
| `PROCESS_SPAWN_FAILED` | Child process failed to start (bad command, permissions) |
| `PROCESS_TIMEOUT` | Process exceeded its configured timeout |

---

## API Reference

### KadiClient

```typescript
new KadiClient(config: ClientConfig)
```

**Configuration:**

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `name` | `string` | required | Agent name |
| `version` | `string` | `'1.0.0'` | Agent version |
| `brokers` | `Record<string, string \| BrokerEntry>` | `{}` | Broker name → URL string or `{ url, networks? }` object |
| `defaultBroker` | `string` | first broker | Default broker to use |
| `networks` | `string[]` | `['global']` | Networks to join |
| `autoReconnect` | `boolean` | `true` | Auto-reconnect on disconnect |
| `maxReconnectDelay` | `number` | `30000` | Max backoff delay (ms) |
| `requestTimeout` | `number` | `30000` | Default request timeout (ms) |

**Methods:**

| Method | Description |
|--------|-------------|
| `registerTool(def, handler, options?)` | Register a tool. See [Registering Tools](#registering-tools) |
| `connect(broker?)` | Connect to broker |
| `disconnect(broker?)` | Disconnect from broker |
| `invokeRemote(tool, params, options?)` | Invoke tool via broker (broker picks provider) |
| `load(name, options?)` | **Recommended.** Load ability by transport. See [Loading Abilities](#loading-abilities) |
| `loadNative(name, options?)` | Load ability in-process (advanced) |
| `loadStdio(name, options?)` | Load ability as child process (advanced) |
| `loadBroker(name, options?)` | Load ability via broker (advanced) |
| `subscribe(pattern, handler, options?)` | Subscribe to broker events. See [Broker Events](#broker-events-pubsub) |
| `unsubscribe(pattern, handler, options?)` | Unsubscribe from events |
| `publish(channel, data, options?)` | Publish event to broker |
| `emit(event, data)` | Emit event to consumer (when serving as ability) |
| `serve(mode)` | Serve as ability (`'stdio'` or `'broker'`) |
| `refreshBrokerTools(broker?)` | Recapture tool snapshot and re-announce to broker. See [Reconnection](#reconnection) |
| `onDisconnect(hook)` | Register callback for broker disconnect. See [Lifecycle Hooks](#lifecycle-hooks) |
| `onReconnect(hook)` | Register callback for broker reconnect. See [Lifecycle Hooks](#lifecycle-hooks) |
| `getConnectedBrokers()` | List connected broker names |

**Properties:**

| Property | Type | Description |
|----------|------|-------------|
| `agentJson` | `AgentJsonManager` | Lazy-initialized agent.json manager. See [Agent.json Management](#agentjson-management) |
| `processes` | `ProcessManager` | Lazy-initialized process manager. See [Process Manager](#process-manager) |
| `crypto` | `CryptoService` | Lazy-initialized encryption service. See [Encryption (Crypto)](#encryption-crypto) |

### LoadedAbility

Returned by `load()` (or the transport-specific `loadNative()`, `loadStdio()`, `loadBroker()`):

| Property/Method | Description |
|-----------------|-------------|
| `name` | Ability name |
| `transport` | `'native'` \| `'stdio'` \| `'broker'` |
| `invoke(tool, params)` | Invoke a tool |
| `getTools()` | Get array of tool definitions |
| `on(event, handler)` | Subscribe to events |
| `off(event, handler)` | Unsubscribe from events |
| `disconnect()` | Cleanup resources |

### BrokerEvent

Received when subscribed to broker events:

```typescript
interface BrokerEvent {
  channel: string;      // 'user.login'
  data: unknown;        // Event payload
  networkId: string;    // Which network
  source: string;       // Publisher's session ID
  timestamp: number;    // Unix timestamp (ms)
}
```

### AgentJsonManager

```typescript
new AgentJsonManager(options?: AgentJsonManagerOptions)
```

**Options:**

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `projectRoot` | `string` | auto-detect | Path to agent project root |
| `kadiHome` | `string` | `~/.kadi` | Path to KADI home directory |
| `createOnWrite` | `boolean` | `true` | Create agent.json if missing on write |

**Methods:**

| Method | Returns | Description |
|--------|---------|-------------|
| `readProject(field?)` | `Promise<unknown>` | Read project agent.json (optional dot-path field) |
| `readAbility(name, options?)` | `Promise<unknown>` | Read an installed ability's agent.json |
| `readHome(field?)` | `Promise<unknown>` | Read global ~/.kadi/agent.json |
| `writeProject(path, value)` | `Promise<void>` | Write/deep-merge a field in project agent.json |
| `writeAbility(name, path, value, version?)` | `Promise<void>` | Write a field in an ability's agent.json |
| `writeHome(path, value)` | `Promise<void>` | Write a field in global agent.json |
| `deleteProject(path)` | `Promise<boolean>` | Delete a field from project agent.json |
| `deleteAbility(name, path, version?)` | `Promise<boolean>` | Delete a field from an ability's agent.json |
| `deleteHome(path)` | `Promise<boolean>` | Delete a field from global agent.json |
| `listAbilities()` | `Promise<AbilityInfo[]>` | List all installed abilities |
| `hasAbility(name)` | `Promise<boolean>` | Check if an ability is installed |
| `getAbilityVersions(name)` | `Promise<string[]>` | Get installed versions of an ability |
| `getPaths()` | `Promise<AgentJsonPaths>` | Get resolved file paths for all scopes |

### ProcessManager

```typescript
new ProcessManager()
```

**Methods:**

| Method | Returns | Description |
|--------|---------|-------------|
| `spawn(id, options)` | `Promise<ManagedProcess>` | Spawn a new background process |
| `get(id)` | `ManagedProcess` | Get handle to an existing process (throws if not found) |
| `getStatus(id)` | `ProcessState` | Get process state: `'running'` \| `'exited'` \| `'killed'` \| `'errored'` |
| `list(options?)` | `ManagedProcess[]` | List all managed processes (optional mode filter) |
| `kill(id)` | `Promise<void>` | Kill a process by ID |
| `killAll()` | `Promise<void>` | Kill all managed processes |
| `shutdown()` | `Promise<void>` | Kill all and clean up |
| `prune()` | `number` | Remove exited/errored entries, returns count pruned |

**SpawnOptions:**

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `command` | `string` | required | Command to run |
| `args` | `string[]` | `[]` | Command arguments |
| `mode` | `ProcessMode` | required | `'headless'` \| `'piped'` \| `'bridge'` |
| `cwd` | `string` | `process.cwd()` | Working directory |
| `env` | `Record<string, string>` | inherited | Environment variables |
| `timeout` | `number` | none | Auto-kill after N milliseconds |
| `killGracePeriod` | `number` | `5000` | Time between SIGTERM and SIGKILL |

### ManagedProcess

Returned by `ProcessManager.spawn()`. Extends `EventEmitter`.

| Property/Method | Description |
|-----------------|-------------|
| `id` | Unique process identifier |
| `pid` | OS process ID |
| `mode` | `'headless'` \| `'piped'` \| `'bridge'` |
| `state` | Current state: `'running'` \| `'exited'` \| `'killed'` \| `'errored'` |
| `request(method, params?)` | **Bridge only.** Send JSON-RPC request, returns `Promise<unknown>` |
| `notify(method, params?)` | **Bridge only.** Send JSON-RPC notification (no response) |
| `write(data)` | Write raw data to stdin |
| `kill()` | Kill the process (SIGTERM → grace → SIGKILL) |
| `waitForExit()` | `Promise<ProcessExitInfo>` — resolves when process exits |
| `getOutput()` | **Piped only.** Get buffered `{ stdout, stderr }` |
| `getInfo()` | Get `ProcessInfo` snapshot |

**Events:**

| Event | Payload | Modes |
|-------|---------|-------|
| `stdout` | `string` | piped |
| `stderr` | `string` | piped |
| `exit` | `ProcessExitInfo` | all |
| `error` | `Error` | all |
| `notification` | `{ method, params }` | bridge |

### CryptoService

Accessed via `client.crypto`. Lazy-initialized from the agent's Ed25519 identity keys.

**Properties:**

| Property | Type | Description |
|----------|------|-------------|
| `publicKey` | `string` | X25519 encryption public key (base64). Share with senders. Cached after first access |
| `identityPublicKey` | `string` | Ed25519 identity public key (SPKI DER base64). Same as `client.publicKey` |

**Methods:**

| Method | Returns | Description |
|--------|---------|-------------|
| `encryptFor(plaintext, recipientPublicKey)` | `string` | Encrypt string for recipient. Returns base64 sealed box. Accepts Ed25519 or X25519 keys |
| `decrypt(ciphertext)` | `string` | Decrypt base64 sealed box. Throws on failure |

---

## Troubleshooting

### "Cannot find module" with loadNative/loadStdio

The ability isn't in your `agent-lock.json`. Either:
- Run `kadi install` to install abilities and generate the lock file
- Use the `path` option: `loadNative('calc', { path: './path/to/ability' })`
- Use explicit command: `loadStdio('calc', { command: 'node', args: ['calc.js'] })`

### Connection timeout to broker

1. Check the broker is running
2. Verify the URL uses `ws://` or `wss://`, not `http://`
3. Check firewall/network allows WebSocket connections

### Events not being received

1. Verify both publisher and subscriber are on the same network (check `networks` config)
2. Check your pattern matches the channel (remember: `*` = one word, `#` = zero or more)
3. Ensure you're connected before subscribing: `await client.connect()`

### Tool invocation times out

Increase the timeout:
```typescript
await client.invokeRemote('slow-tool', params, { timeout: 60000 });
```

### AGENT_JSON_NOT_FOUND errors

The manager can't find `agent.json` at the expected path. Either:
- Ensure you're running from inside a project with `agent.json` and `agent-lock.json`
- Pass an explicit `projectRoot` when constructing `AgentJsonManager`

### PROCESS_BRIDGE_ERROR in bridge mode

The child process isn't speaking the expected Content-Length framed JSON-RPC protocol. Ensure:
1. The child writes `Content-Length: <n>\r\n\r\n<json>` to stdout
2. The child reads the same framing from stdin
3. No other output is mixed into stdout (use stderr for logging)

---

## Advanced: Building CLI Tools

These utilities are exported for building tooling on top of kadi-core.

### Zod to JSON Schema

Convert Zod schemas to JSON Schema (useful for generating documentation or OpenAPI specs):

```typescript
import { zodToJsonSchema, z } from '@kadi.build/core';

const schema = z.object({ name: z.string(), age: z.number() });
const jsonSchema = zodToJsonSchema(schema);
// { type: 'object', properties: { name: { type: 'string' }, ... } }
```

### Lock File Resolution

Utilities for working with `agent-lock.json` (the file that tracks installed abilities):

```typescript
import {
  findProjectRoot,
  readLockFile,
  resolveAbilityPath,
  getInstalledAbilityNames,
} from '@kadi.build/core';

// Find the nearest directory containing agent-lock.json
const root = findProjectRoot();

// Read and parse the lock file
const lock = readLockFile(root);

// List installed ability names
const abilities = getInstalledAbilityNames(lock);
// ['calculator', 'image-processor', ...]

// Get the filesystem path to an ability
const calcPath = resolveAbilityPath('calculator', root);
// '/path/to/project/abilities/calculator@1.0.0'
```

### Dot-Path Utilities

Low-level helpers for nested object access (used internally by `AgentJsonManager`):

```typescript
import { getByPath, setByPath, deleteByPath, deepMerge } from '@kadi.build/core';

const obj = { deploy: { local: { target: 'docker' } } };

getByPath(obj, 'deploy.local.target');  // 'docker'
setByPath(obj, 'deploy.staging.target', 'akash');
deleteByPath(obj, 'deploy.local');

// Deep merge (objects merged recursively, arrays replaced)
const merged = deepMerge(
  { deploy: { replicas: 1, target: 'docker' } },
  { deploy: { replicas: 3 } },
);
// { deploy: { replicas: 3, target: 'docker' } }
```

### Stdio Framing

For building custom Content-Length framed JSON-RPC protocols (used internally by stdio transport and bridge mode):

```typescript
import { StdioMessageReader, StdioMessageWriter } from '@kadi.build/core';

// Wrap a readable stream (e.g., child.stdout)
const reader = new StdioMessageReader(childProcess.stdout);
reader.onMessage((msg) => console.log('Received:', msg));

// Wrap a writable stream (e.g., child.stdin)
const writer = new StdioMessageWriter(childProcess.stdin);
writer.write({ jsonrpc: '2.0', method: 'ping', params: {} });
```

---

## Related

- [kadi-core-py](https://gitlab.com/humin-game-lab/kadi/kadi-core-py) — Python SDK (mirrors this API)
- [kadi-broker](https://gitlab.com/humin-game-lab/kadi/kadi-broker) — The broker server

---

## License

MIT