@kadi.build/core
v0.15.2
Published
A lean, readable SDK for building KADI agents.
Readme
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.ymlwith 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
npm install @kadi.build/coreRequirements:
- 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:
// 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:
Table of Contents
- Core Concepts
- Registering Tools
- Connecting to Brokers
- Loading Abilities
- Events from Abilities
- Broker Events (Pub/Sub)
- Serving as an Ability
- Agent.json Management
- Process Manager
- Encryption (Crypto)
- Installing as an Ability
- Configuration (loadConfig)
- Error Handling
- API Reference
- 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.
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
// 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.
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:
// 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:
client.registerTool(definition, handler, {
brokers: ['production'],
networks: ['internal'],
});Connecting to Brokers
Brokers let agents discover and call each other without knowing URLs.
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
});
// 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:
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():
// 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:
// 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
Three ways to connect to other agents:
| Method | Use When | How It Works |
|--------|----------|--------------|
| loadNative(name) | Ability is a local TypeScript/JavaScript module | Runs in your process |
| loadStdio(name) | Ability runs as a separate process (any language) | JSON-RPC over stdin/stdout |
| loadBroker(name) | Ability is a remote service | WebSocket via broker |
loadNative — In-Process
// Resolves path from agent-lock.json
const calc = await client.loadNative('calculator');
// Or specify path directly
const calc = await client.loadNative('calculator', { path: './abilities/calc' });
// Use it
const result = await calc.invoke('add', { a: 5, b: 3 });
console.log(result); // { result: 8 }
// Cleanup (no-op for native, but good practice)
await calc.disconnect();loadStdio — Child Process
By default, launches the ability using scripts.start from the ability's agent.json:
// Runs scripts.start from ability's agent.json
const processor = await client.loadStdio('image-processor');
// Use it
const result = await processor.invoke('resize', { width: 800 });
console.log(result);
// Cleanup (terminates child process)
await processor.disconnect();You can specify a different script to launch:
// Run scripts.dev instead of scripts.start
const processor = await client.loadStdio('image-processor', { script: 'dev' });Or bypass agent.json entirely with an explicit command:
// Explicit command (ignores agent.json)
const processor = await client.loadStdio('processor', {
command: 'python3',
args: ['main.py'],
});loadBroker — Remote via Broker
// Must be connected to broker first
await client.connect();
// Find and connect to a remote ability
const gpu = await client.loadBroker('gpu-service', {
networks: ['gpu-cluster'],
});
const result = await gpu.invoke('inference', { model: 'llama3' });
await gpu.disconnect();Events from Abilities
Abilities can emit real-time events. Subscribe with on(), unsubscribe with off().
const watcher = await client.loadStdio('file-watcher');
// 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:
// 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
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.*→ matchesuser.login,user.logout(exactly one word after dot)user.#→ matchesuser,user.login,user.profile.update(zero or more words)order.new→ matches exactlyorder.new
Publishing
// 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
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)
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)
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:
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:
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
// 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
// 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
// List all installed abilities (reads agent-lock.json)
const abilities = await client.agentJson.listAbilities();
// [{ name: 'calculator', version: '1.0.0', path: '/abs/path/abilities/[email protected]' }, ...]
// 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
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
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 stringBridge 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:
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
// 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:
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
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
// 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
// Agent B decrypts
const plaintext = agentB.crypto.decrypt(encrypted);
const secrets = JSON.parse(plaintext);Getting Your Encryption Public Key
// 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.publicKeyformat, auto-converted to X25519 - Raw X25519 base64 (32 bytes decoded) — used directly, no conversion needed
// Both work:
client.crypto.encryptFor('secret', otherAgent.publicKey); // Ed25519 → auto-converted
client.crypto.encryptFor('secret', otherAgent.crypto.publicKey); // X25519 → used directlyInstalling 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:
kadi install kadi-coreAfter 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:
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 installrather thannpm install - Deployed agents where abilities are resolved from
agent-lock.json
Configuration (loadConfig)
Load settings from config.yml with a standardized 3-tier resolution:
- Environment variables —
PREFIX_KEY(highest priority) - Project config.yml — walk up from CWD to find the nearest
config.yml - Global
~/.kadi/config.yml— shared KADI infrastructure settings - Built-in defaults — hardcoded fallbacks (lowest priority)
Secrets (API keys, tokens) should never go in config.yml. Use kadi secret for credentials.
Basic Usage
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:
tunnel:
server_addr: kadi.build:7835
tunnel_domain: tunnels.kadi.build
memory:
database: kadi_memory
embedding_model: text-embedding-3-smallOptions
| 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
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
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:
loadConfigrequiresjs-yamlat runtime. It is lazy-imported — if your project doesn't useloadConfig, you don't needjs-yamlinstalled.
Error Handling
All errors are KadiError with structured codes and context:
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
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> | {} | Broker name → URL map |
| 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 |
| connect(broker?) | Connect to broker |
| disconnect(broker?) | Disconnect from broker |
| invokeRemote(tool, params, options?) | Invoke tool via broker (broker picks provider) |
| loadNative(name, options?) | Load ability in-process. See Loading Abilities |
| loadStdio(name, options?) | Load ability as child process |
| loadBroker(name, options?) | Load ability via broker |
| subscribe(pattern, handler, options?) | Subscribe to broker events. See Broker Events |
| 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 |
| onDisconnect(hook) | Register callback for broker disconnect. See Lifecycle Hooks |
| onReconnect(hook) | Register callback for broker reconnect. See Lifecycle Hooks |
| getConnectedBrokers() | List connected broker names |
Properties:
| Property | Type | Description |
|----------|------|-------------|
| agentJson | AgentJsonManager | Lazy-initialized agent.json manager. See Agent.json Management |
| processes | ProcessManager | Lazy-initialized process manager. See Process Manager |
| crypto | CryptoService | Lazy-initialized encryption service. See Encryption (Crypto) |
LoadedAbility
Returned by 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:
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
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
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 installto install abilities and generate the lock file - Use the
pathoption:loadNative('calc', { path: './path/to/ability' }) - Use explicit command:
loadStdio('calc', { command: 'node', args: ['calc.js'] })
Connection timeout to broker
- Check the broker is running
- Verify the URL uses
ws://orwss://, nothttp:// - Check firewall/network allows WebSocket connections
Events not being received
- Verify both publisher and subscriber are on the same network (check
networksconfig) - Check your pattern matches the channel (remember:
*= one word,#= zero or more) - Ensure you're connected before subscribing:
await client.connect()
Tool invocation times out
Increase the timeout:
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.jsonandagent-lock.json - Pass an explicit
projectRootwhen constructingAgentJsonManager
PROCESS_BRIDGE_ERROR in bridge mode
The child process isn't speaking the expected Content-Length framed JSON-RPC protocol. Ensure:
- The child writes
Content-Length: <n>\r\n\r\n<json>to stdout - The child reads the same framing from stdin
- 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):
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):
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/[email protected]'Dot-Path Utilities
Low-level helpers for nested object access (used internally by AgentJsonManager):
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):
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 — Python SDK (mirrors this API)
- kadi-broker — The broker server
License
MIT
