Skip to content

Latest commit

 

History

History
1369 lines (1070 loc) · 48.4 KB

sdk.md

File metadata and controls

1369 lines (1070 loc) · 48.4 KB

Diffex can help you use the SDK. Ask it to build an integration for your use case.

SDK

The SDK provides programmatic access to Diffex's agent capabilities. Use it to embed Diffex in other applications, build custom interfaces, or integrate with automated workflows.

Example use cases:

  • Build a custom UI (web, desktop, mobile)
  • Integrate agent capabilities into existing applications
  • Create automated pipelines with agent reasoning
  • Build custom tools that spawn sub-agents
  • Test agent behavior programmatically

See examples/sdk/ for working examples from minimal to full control.

Quick Start

import { createAgentSession, ModelRuntime, SessionManager } from "@diffexai/diffex";

const modelRuntime = await ModelRuntime.create();
const { session } = await createAgentSession({
  sessionManager: SessionManager.inMemory(),
  modelRuntime,
});

session.subscribe((event) => {
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
    process.stdout.write(event.assistantMessageEvent.delta);
  }
});

await session.prompt("What files are in the current directory?");
await session.dispose();

Installation

npm install @diffexai/diffex

The SDK is included in the main package. No separate installation needed.

Core Concepts

createAgentSession()

The main factory function for a single AgentSession.

createAgentSession() uses a ResourceLoader to supply extensions, skills, prompt templates, themes, and context files. If you do not provide one, it uses DefaultResourceLoader with standard discovery.

import { createAgentSession, SessionManager } from "@diffexai/diffex";

// Minimal: defaults with DefaultResourceLoader
const { session } = await createAgentSession();

// Custom: override specific options
const { session } = await createAgentSession({
  model: myModel,
  tools: ["read", "bash"],
  sessionManager: SessionManager.inMemory(),
});

AgentSession

The session manages agent lifecycle, message history, model state, compaction, and event streaming. With automatic compaction enabled, it checks effective context usage after every completed tool-result batch, including new tool output, before requesting the next model response. When the threshold is reached, the session compacts and continues automatically while preserving tool-call/result pairs.

interface AgentSession {
  // Send a prompt and wait for completion
  prompt(text: string, options?: PromptOptions): Promise<void>;

  // Queue messages during streaming
  steer(text: string): Promise<void>;
  followUp(text: string): Promise<void>;

  // Subscribe to events (returns unsubscribe function)
  subscribe(listener: (event: AgentSessionEvent) => void): () => void;

  // Session info
  sessionFile: string | undefined;
  sessionId: string;

  // Model control
  setModel(model: Model): Promise<void>;
  setThinkingLevel(level: ThinkingLevel): void;
  cycleModel(): Promise<ModelCycleResult | undefined>;
  cycleThinkingLevel(): ThinkingLevel | undefined;

  // State access
  agent: Agent;
  model: Model | undefined;
  thinkingLevel: ThinkingLevel;
  messages: AgentMessage[];
  isStreaming: boolean;

  // In-place tree navigation within the current session file
  navigateTree(targetId: string, options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }): Promise<{ editorText?: string; cancelled: boolean }>;

  // Compaction
  compact(customInstructions?: string): Promise<CompactionResult>;
  abortCompaction(): void;

  // Abort current operation
  abort(): Promise<void>;

  // Cleanup
  dispose(): Promise<void>;
}

Always await dispose() before releasing process resources or removing a session workspace. Disposal closes child-agent admission immediately, then waits for active root and child work to stop and for all session resources to be released.

Session replacement APIs such as new-session, resume, fork, and import live on AgentSessionRuntime, not on AgentSession.

createAgentSessionRuntime() and AgentSessionRuntime

Use the runtime API when you need to replace the active session and rebuild cwd-bound runtime state. This is the same layer used by the built-in interactive, print, and RPC modes.

createAgentSessionRuntime() takes a runtime factory plus the initial cwd/session target. The factory closes over process-global fixed inputs, recreates cwd-bound services for the effective cwd, resolves session options against those services, and returns a full runtime result.

import {
  type CreateAgentSessionRuntimeFactory,
  createAgentSessionFromServices,
  createAgentSessionRuntime,
  createAgentSessionServices,
  getAgentDir,
  SessionManager,
} from "@diffexai/diffex";

const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
  const services = await createAgentSessionServices({ cwd });
  return {
    ...(await createAgentSessionFromServices({
      services,
      sessionManager,
      sessionStartEvent,
    })),
    services,
    diagnostics: services.diagnostics,
  };
};

const runtime = await createAgentSessionRuntime(createRuntime, {
  cwd: process.cwd(),
  agentDir: getAgentDir(),
  sessionManager: SessionManager.create(process.cwd()),
});

AgentSessionRuntime owns replacement of the active runtime across:

  • newSession()
  • switchSession()
  • fork()
  • clone flows via fork(entryId, { position: "at" })
  • importFromJsonl()

Important behavior:

  • runtime.session changes after those operations
  • event subscriptions are attached to a specific AgentSession, so re-subscribe after replacement
  • if you use extensions, call runtime.session.bindExtensions(...) again for the new session
  • creation returns diagnostics on runtime.diagnostics
  • if runtime creation or replacement fails, the method throws and the caller decides how to handle it
let session = runtime.session;
let unsubscribe = session.subscribe(() => {});

await runtime.newSession();

unsubscribe();
session = runtime.session;
unsubscribe = session.subscribe(() => {});

Prompting and Message Queueing

PromptOptions controls prompt expansion, queueing behavior while streaming, cancellation, and prompt preflight notifications:

interface PromptOptions {
  expandPromptTemplates?: boolean;
  images?: ImageContent[];
  streamingBehavior?: "steer" | "followUp";
  source?: InputSource;
  preflightResult?: (success: boolean) => void;
  signal?: AbortSignal;
}

preflightResult is called once per prompt() invocation:

  • true when the prompt was accepted, queued, or handled immediately
  • false when prompt preflight rejected before acceptance

It fires before prompt() resolves. prompt() still resolves only after the full accepted run finishes, including retries and coordinated child-agent batches. Failures after acceptance are reported through the normal event and message stream, not through preflightResult(false).

A prompt with signal reserves the complete prompt lifecycle synchronously, including asynchronous preflight and the active run. It rejects instead of entering active-run queueing when another prompt is in preflight or an agent run is active, and later prompts reject while its reservation is held. Cancellation before agent_start rejects with the exact signal.reason, cancels pre-prompt compaction and authentication where active, and starts no user or provider turn. Cancellation after agent_start uses the existing session abort behavior, so prompt() settles through the normal aborted-response event and persistence path rather than rejecting again with signal.reason. Prompts without signal retain the existing streamingBehavior queue behavior during an active run.

The prompt() method handles prompt templates, extension commands, and message sending:

// Basic prompt (when not streaming)
await session.prompt("What files are here?");

// With images
await session.prompt("What's in this image?", {
  images: [{ type: "image", source: { type: "base64", mediaType: "image/png", data: "..." } }]
});

// During streaming: must specify how to queue the message
await session.prompt("Stop and do this instead", { streamingBehavior: "steer" });
await session.prompt("After you're done, also check X", { streamingBehavior: "followUp" });

// Reserve and cancel the complete prompt lifecycle
const controller = new AbortController();
const pending = session.prompt("Inspect the repository", { signal: controller.signal });
controller.abort(new Error("Request cancelled"));
await pending.catch((error) => console.error(error));

Behavior:

  • Extension commands (e.g., /mycommand): Execute immediately, even during streaming. They manage their own LLM interaction via diffex.sendMessage().
  • File-based prompt templates (from .md files): Expanded to their content before sending or queueing.
  • During streaming without streamingBehavior: Throws an error. Use steer() or followUp() directly, or specify the option.
  • preflightResult(true): Means the prompt was accepted, queued, or handled immediately.
  • preflightResult(false): Means preflight rejected before acceptance.

For explicit queueing during streaming:

// Queue a steering message for delivery after the current assistant turn finishes its tool calls
await session.steer("New instruction");

// Wait for agent to finish (delivered only when agent stops)
await session.followUp("After you're done, also do this");

Both steer() and followUp() expand file-based prompt templates but error on extension commands (extension commands cannot be queued).

Agent and AgentState

The Agent class (from @diffexai/diffex-agent-core) handles the core LLM interaction. Access it via session.agent.

// Access current state
const state = session.agent.state;

// state.messages: AgentMessage[] - conversation history
// state.model: Model - current model
// state.thinkingLevel: ThinkingLevel - current thinking level
// state.systemPrompt: string - system prompt
// state.tools: AgentTool[] - available tools
// state.streamingMessage?: AgentMessage - current partial assistant message
// state.errorMessage?: string - latest assistant error

// Replace messages (useful for branching or restoration)
session.agent.state.messages = messages; // copies the top-level array

// Replace tools
session.agent.state.tools = tools; // copies the top-level array

// Wait for agent to finish processing
await session.agent.waitForIdle();

Events

Subscribe to events to receive streaming output and lifecycle notifications.

session.subscribe((event) => {
  switch (event.type) {
    // Streaming text from assistant
    case "message_update":
      if (event.assistantMessageEvent.type === "text_delta") {
        process.stdout.write(event.assistantMessageEvent.delta);
      }
      if (event.assistantMessageEvent.type === "thinking_delta") {
        // Thinking output (if thinking enabled)
      }
      break;
    
    // Tool execution
    case "tool_execution_start":
      console.log(`Tool: ${event.toolName}`);
      break;
    case "tool_execution_update":
      // Streaming tool output
      break;
    case "tool_execution_end":
      console.log(`Result: ${event.isError ? "error" : "success"}`);
      break;
    
    // Message lifecycle
    case "message_start":
      // New message starting
      break;
    case "message_end":
      // Message complete
      break;
    
    // Agent lifecycle
    case "agent_start":
      // Agent started processing prompt
      break;
    case "agent_end":
      // Agent finished (event.messages contains new messages)
      break;
    
    // Turn lifecycle (one LLM response + tool calls)
    case "turn_start":
      break;
    case "turn_end":
      // event.message: assistant response
      // event.toolResults: tool results from this turn
      break;
    
    // Session events (queue, compaction, retry, child coordination)
    case "queue_update":
      console.log(event.steering, event.followUp);
      break;
    case "subagent_wait_start":
      console.log(`Waiting for: ${event.names.join(", ")}`);
      break;
    case "subagent_notice":
      console.log(event.text);
      break;
    case "subagent_wait_end":
      console.log(`Child wait cancelled: ${event.cancelled}`);
      break;
    case "compaction_start":
    case "compaction_end":
    case "auto_retry_start":
    case "auto_retry_end":
    case "summarization_retry_scheduled":
    case "summarization_retry_attempt_start":
    case "summarization_retry_finished":
      break;
  }
});

subagent_wait_start identifies the frozen, name-sorted batch that must finish before the parent settles. subagent_notice is emitted as each child terminates and contains only the child name, terminal status, attempt kind, retry availability, and deterministic notice text. It never contains child results, partial output, or errors. subagent_wait_end closes the wait and reports whether it was cancelled. These lifecycle events are not added to agent messages, persisted as session entries, or included in provider context.

Options Reference

Directories

const { session } = await createAgentSession({
  // Working directory for DefaultResourceLoader discovery
  cwd: process.cwd(), // default
  
  // Global config directory
  agentDir: "~/.diffex/agent", // default (expands ~)
});

cwd is used by DefaultResourceLoader for:

  • Project extensions (.diffex/extensions/)
  • Project skills:
    • .diffex/skills/
    • .agents/skills/ in cwd and ancestor directories (up to git repo root, or filesystem root when not in a repo)
  • Project prompts (.diffex/prompts/)
  • Context files (AGENTS.md walking up from cwd)
  • Session directory naming

agentDir is used by DefaultResourceLoader for:

  • Global extensions (extensions/)
  • Global skills:
    • skills/ under agentDir (for example ~/.diffex/agent/skills/)
    • ~/.agents/skills/
  • Global prompts (prompts/)
  • Global context file (AGENTS.md)
  • Settings (settings.json)
  • Custom models (models.json)
  • Credentials (auth.json)
  • Sessions (sessions/)

When you pass a custom ResourceLoader, cwd and agentDir no longer control resource discovery. They still influence session naming and tool path resolution.

Model

import { getModel } from "@diffexai/diffex-ai";
import { ModelRuntime } from "@diffexai/diffex";

const modelRuntime = await ModelRuntime.create();

// Find specific built-in model (doesn't check if API key exists)
const opus = getModel("anthropic", "claude-opus-4-5");
if (!opus) throw new Error("Model not found");

// Find any model by provider/id, including custom models from models.json
// (doesn't check if API key exists)
const customModel = modelRuntime.getModel("my-provider", "my-model");

// Get only models that have valid authentication configured
const available = await modelRuntime.getAvailable();

const { session } = await createAgentSession({
  model: opus,
  thinkingLevel: "medium", // off, minimal, low, medium, high, xhigh, max
  
  // Models for cycling (Ctrl+P in interactive mode)
  scopedModels: [
    { model: opus, thinkingLevel: "high" },
    { model: haiku, thinkingLevel: "off" },
  ],
  
  modelRuntime,
});

If no model is provided:

  1. Tries to restore from session (if continuing)
  2. Uses default from settings
  3. Falls back to first available model

To match CLI model parsing, use the exported resolver helpers:

import {
  resolveCliModel,
  resolveModelScopeWithDiagnostics,
} from "@diffexai/diffex";

const cliModel = resolveCliModel({
  cliModel: "anthropic/claude-opus-4-5:high",
  modelRuntime,
});
if (cliModel.error) throw new Error(cliModel.error);
if (cliModel.warning) console.warn(cliModel.warning);

const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(
  ["anthropic/*:high", "gpt-5"],
  modelRuntime,
);
for (const diagnostic of diagnostics) {
  console.warn(diagnostic.message);
}

resolveCliModel() uses all registered models so --api-key style first-time setup can resolve a model before stored auth exists. resolveModelScopeWithDiagnostics() matches --models and enabledModels semantics while returning warnings instead of printing them.

See examples/sdk/02-custom-model.ts

API Keys and OAuth

Authentication resolution priority (handled by ModelRuntime):

  1. Runtime overrides (via setRuntimeApiKey, not persisted)
  2. Stored credentials in auth.json (API keys or OAuth tokens)
  3. Environment variables (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)
  4. Fallback resolver (for custom provider keys from models.json)
import { InMemoryCredentialStore } from "@diffexai/diffex-ai";
import { createAgentSession, ModelRuntime } from "@diffexai/diffex";

// Default: uses ~/.diffex/agent/auth.json and ~/.diffex/agent/models.json
const modelRuntime = await ModelRuntime.create();

// Provider-owned auth methods and current status
for (const provider of modelRuntime.getProviders()) {
  const status = await modelRuntime.checkAuth(provider.id);
  console.log(provider.name, provider.auth, status);
}

// Runtime API key override (not persisted to disk)
await modelRuntime.setRuntimeApiKey("anthropic", "sk-my-temp-key");

// Custom credential and model locations
const customRuntime = await ModelRuntime.create({
  authPath: "/my/app/auth.json",
  modelsPath: "/my/app/models.json",
});

// Or inject any diffex-ai CredentialStore
const credentials = new InMemoryCredentialStore();
const inMemoryRuntime = await ModelRuntime.create({ credentials });

const { session } = await createAgentSession({
  modelRuntime: customRuntime,
});

login(), logout(), setRuntimeApiKey(), and removeRuntimeApiKey() resolve after the affected provider's cached/built-in catalog, composition, and availability snapshot are locally consistent. They do not wait for remote catalog freshness. If credentials were committed but local synchronization fails, they reject with the exported CredentialSynchronizationError; inspect its providerId, operation, credential, and cause fields instead of retrying the credential mutation blindly.

Public model/auth operations and ModelRuntime.create({ signal }) accept optional abort signals and are unbounded when omitted. SDK applications own deadline policy for remote catalog freshness:

const signal = AbortSignal.timeout(15_000);
const result = await modelRuntime.refresh({
  providers: ["anthropic"],
  signal,
});
if (result.aborted) console.warn("Catalog refresh timed out; using cached models");
for (const [providerId, error] of result.errors) {
  console.warn(`Could not refresh ${providerId}:`, error);
}

A failed or timed-out network refresh does not undo a successful credential operation. refresh() starts a new provider generation, so it does not wait behind an older stalled refresh and stale generations cannot publish afterward.

See examples/sdk/09-api-keys-and-oauth.ts

System Prompt

Use a ResourceLoader to override the system prompt:

import { createAgentSession, DefaultResourceLoader } from "@diffexai/diffex";

const loader = new DefaultResourceLoader({
  systemPromptOverride: () => "You are a helpful assistant.",
});
await loader.reload();

const { session } = await createAgentSession({ resourceLoader: loader });

See examples/sdk/03-custom-prompt.ts

Tools

Every root session enables three child-agent coordination tools by default:

  • spawn_agent starts a child agent asynchronously, while keeping the enclosing parent prompt active until coordinated work finishes
  • agent_action steers, queues follow-up work, wakes, renews, or interrupts a child agent
  • agent_status immediately inspects child statuses, returning recorded output and errors

Call agent_status with {} to inspect all children, or with { targets: ["worker"] } to inspect selected children. It returns immediately with { agents: [...] } and accepts no timeout or wait condition. When only needed child work remains, end the current response; automatic coordination keeps the parent task open and resumes it with child reports or results. Continue independent work when possible instead of repeatedly polling status. Tool allowlists and direct tool calls must use agent_status in place of wait_for_agents.

Root sessions run at most four children concurrently by default. Set subagents: { maxConcurrent: number } to configure that limit, or subagents: false to disable child agents and omit all three coordination tools. The names spawn_agent, agent_action, agent_status, and escalate_to_parent are reserved and cannot be supplied through customTools.

Messages default to delivery: "steer" and enter the child's current work at its next steering boundary. Use delivery: "follow_up" explicitly for new work that should start after the current work finishes. Set wake: true to resume an idle child; steering an idle child without waking it fails instead of silently queuing instructions.

Child runs have a renewable 15-minute allowance, including time queued for capacity. Configure subagents.leaseMs, subagents.supervisionIntervalMs (default 120,000), and subagents.parentDeadline (an absolute Unix timestamp in milliseconds). CLI users can set DIFFEX_SUBAGENT_LEASE_MS, DIFFEX_SUBAGENT_SUPERVISION_MS, and DIFFEX_TASK_DEADLINE_MS; explicit SDK options take precedence. Supply the parent's deadline when known; renewal never extends beyond it, and parent cancellation remains authoritative. Approve a needed extension with agent_action using action: "renew" and a reason based on current progress, remaining work, and the goal. Each approval adds one configured allowance (15 minutes by default) to the existing child deadline, capped by the parent deadline. The parent does not specify a renewal duration. Cancel obsolete work immediately instead of waiting for expiration.

Every two minutes by default, runtime supervision reports only running/queued status, elapsed time, and time since the last observed activity to the parent. Near expiration (around minute 12 with defaults), the child receives a steering request to call escalate_to_parent with current progress, remaining work, the goal, and the decision or extension needed. If more time is needed, the child requests an extension without estimating its duration. The checkpoint request is sent once per allowance and rearmed only when renewal extends the deadline. The child receives it at a normal steering boundary after the current assistant turn finishes its tool calls. The checkpoint relies on the child following the instruction; the runtime enforces expiration and cancellation. Reports resume a waiting parent; they do not abort an in-flight model request. Interrupting a child cancels model requests and owned shell process groups, waits for termination verification, and reports cleanup failures. Background shell jobs are also cleaned up when a child finishes normally.

Child sessions intentionally do not load parent extensions. To prevent an extension policy bypass, spawn_agent fails closed when the root has a registered handler for any execution-mutating hook: resources_discover, input, context, before_agent_start, before_provider_request, before_provider_headers, session_before_compact, tool_call, tool_result, or message_end. The error lists every incompatible hook in that order. Remove those handlers before spawning, or set subagents: false; observation-only hooks and non-spawn child actions remain available.

Each live root-owned child also receives the child-only escalate_to_parent tool; roots, standalone sessions, and restored children that have not been explicitly woken do not receive it. It accepts one non-empty message of at most 1,000 characters for material information that could redirect or interrupt the parent, and the child must still return a complete final response. Multiple calls are allowed during one live run, subject to a fixed 8,000-character aggregate limit for accepted report text not yet admitted to parent context.

Active reports enter hidden parent context at normal FIFO steering boundaries without aborting in-flight sampling, and a report received during coordinated waiting resumes the parent without consuming the frozen terminal batch. Idle reports do not start a provider call: durable sessions save them on the selected branch for the next explicit parent run, while in-memory sessions retain them only locally. Accepted idle reports, delivered active reports, and child tool arguments remain normal session history and follow the existing parent-owned deletion behavior.

When the parent finishes an assistant run, the session freezes every unconsumed child attempt spawned through parent model tool calls. Before waiting, it asks the parent to cancel obsolete work or end its current response so the runtime can wait for needed results. It then waits until every child in that batch is idle, failed, or interrupted, then adds one non-displayed context message with name-sorted results, partial output, errors, and retry availability. The parent model continues automatically from that message, so callers do not need to send another user prompt. Terminal results returned explicitly by agent_status are marked consumed and are not delivered again.

An initial child failure offers the parent one coordinated retry for that child during the current explicit parent prompt cycle. The parent can analyze the delivered error and partial output, then call agent_action with action: "message" and wake: true. A second retry in the same cycle is rejected, while a later explicit user prompt starts a fresh cycle. The parent may decline the retry and settle normally.

Calling session.abort() during a coordinated wait interrupts unfinished children in the frozen batch, emits their interrupted notices, and settles without another provider call. Only children spawned by parent model tool calls use automatic coordination. Invoking the spawn_agent tool definition directly through the SDK leaves that child and its retries manually managed.

Specify which tools to enable:

  • Built-in tool names: read, bash, edit, write, update_plan, grep, find, ls
  • Default built-ins: read, bash, edit, write, update_plan
  • noTools: "all" disables all tools
  • noTools: "builtin" disables default built-ins while keeping extension, custom, and coordination tools enabled
  • A tools allowlist must explicitly include each coordination tool that should remain available
  • excludeTools disables specific built-in, extension, custom, or coordination tool names after any tools allowlist is applied

The edit tool returns details.diff for Diffex's TUI display and details.patch as a standard unified patch for SDK consumers. The update_plan tool accepts a full plan, a single-step update by stable ID, or clear: true. Each plan step has an id, step, and status, with optional success_criteria strings. Plan writes and step updates return the complete resolved plan in the model-visible tool result, including IDs, statuses, and success criteria, and in details.plan. The session persists each successful plan change in conversation history, while clear: true returns Plan cleared. Successful compaction summaries include the latest plan snapshot, or an empty plan if the plan was cleared or absent, so resume and fork retain it after older tool results are compacted away. When navigateTree() summarizes the abandoned branch, the saved branch summary includes the destination branch's authoritative plan snapshot and marks plan references in the exploration summary as historical. Selecting a user message uses the plan at its parent, and navigating before any plan or after clearing it records an empty plan. Ordinary requests do not append a separate plan message, allowing subsequent requests to reuse the same prompt prefix.

import { createAgentSession } from "@diffexai/diffex";

// Read-only mode
const { session } = await createAgentSession({
  tools: ["read", "grep", "find", "ls"],
});

// Pick specific tools
const { session } = await createAgentSession({
  tools: ["read", "bash", "grep"],
});

// Disable one tool while keeping the rest available
const { session } = await createAgentSession({
  excludeTools: ["ask_question"],
});

// Allow only read access and child status queries
const { session } = await createAgentSession({
  tools: ["read", "agent_status"],
});

// Disable child agents entirely
const { session } = await createAgentSession({
  subagents: false,
});

// Run at most two children concurrently
const { session } = await createAgentSession({
  subagents: { maxConcurrent: 2 },
});

Tools with Custom cwd

When you pass a custom cwd, createAgentSession() builds selected built-in tools for that cwd.

import { createAgentSession, SessionManager } from "@diffexai/diffex";

const cwd = "/path/to/project";

// Use default tools for custom cwd
const { session } = await createAgentSession({
  cwd,
  sessionManager: SessionManager.inMemory(cwd),
});

// Or pick specific tools for custom cwd
const { session } = await createAgentSession({
  cwd,
  tools: ["read", "bash", "grep"],
  sessionManager: SessionManager.inMemory(cwd),
});

See examples/sdk/05-tools.ts

Custom Tools

import { Type } from "typebox";
import { createAgentSession, defineTool } from "@diffexai/diffex";

// Inline custom tool
const myTool = defineTool({
  name: "my_tool",
  label: "My Tool",
  description: "Does something useful",
  parameters: Type.Object({
    input: Type.String({ description: "Input value" }),
  }),
  execute: async (_toolCallId, params) => ({
    content: [{ type: "text", text: `Result: ${params.input}` }],
    details: {},
  }),
});

// Pass custom tools directly
const { session } = await createAgentSession({
  customTools: [myTool],
});

Use defineTool() for standalone definitions and arrays like customTools: [myTool]. Inline diffex.registerTool({ ... }) already infers parameter types correctly.

Custom tools passed via customTools are combined with extension-registered tools. Extensions loaded by the ResourceLoader can also register tools via diffex.registerTool().

If you pass tools, include each custom or extension tool name you want enabled, for example tools: ["read", "bash", "my_tool"].

See examples/sdk/05-tools.ts

Extensions

Extensions are loaded by the ResourceLoader. DefaultResourceLoader discovers extensions from ~/.diffex/agent/extensions/, .diffex/extensions/, and settings.json extension sources.

import { createAgentSession, DefaultResourceLoader } from "@diffexai/diffex";

const loader = new DefaultResourceLoader({
  additionalExtensionPaths: ["/path/to/my-extension.ts"],
  extensionFactories: [
    (diffex) => {
      diffex.on("agent_start", () => {
        console.log("[Inline Extension] Agent starting");
      });
    },
  ],
});
await loader.reload();

const { session } = await createAgentSession({ resourceLoader: loader });

Extensions can register tools, subscribe to events, add commands, and more. See extensions.md for the full API.

Named inline extensions: By default, inline factories display as <inline:1>, <inline:2>, etc. in the startup Extensions list. To show a descriptive name instead, wrap the factory:

import type { InlineExtension } from "@diffexai/diffex";

const myProvider: InlineExtension = {
  name: "my-provider",
  factory: (diffex) => {
    diffex.on("agent_start", () => {
      console.log("[my-provider] Agent starting");
    });
  },
};

const loader = new DefaultResourceLoader({
  extensionFactories: [myProvider],
});

This displays as <inline:my-provider> instead of <inline:1>. Bare factory functions are still accepted for backward compatibility.

Event Bus: Extensions can communicate via diffex.events. Pass a shared eventBus to DefaultResourceLoader if you need to emit or listen from outside:

import { createEventBus, DefaultResourceLoader } from "@diffexai/diffex";

const eventBus = createEventBus();
const loader = new DefaultResourceLoader({
  eventBus,
});
await loader.reload();

eventBus.on("my-extension:status", (data) => console.log(data));

See examples/sdk/06-extensions.ts and docs/extensions.md

Skills

import {
  createAgentSession,
  DefaultResourceLoader,
  type Skill,
} from "@diffexai/diffex";

const customSkill: Skill = {
  name: "my-skill",
  description: "Custom instructions",
  filePath: "/path/to/SKILL.md",
  baseDir: "/path/to",
  source: "custom",
};

const loader = new DefaultResourceLoader({
  skillsOverride: (current) => ({
    skills: [...current.skills, customSkill],
    diagnostics: current.diagnostics,
  }),
});
await loader.reload();

const { session } = await createAgentSession({ resourceLoader: loader });

See examples/sdk/04-skills.ts

Harness Revisions and Evolved Skills

createAgentSession() loads the selected compatible harness by default. Its memory and workspace-matching evolved skills are pinned together for each admitted request. The SDK does not start invocation tracing or evolution workers; those are composed by the CLI runtime.

const active = session.activeHarnessVersion;
const bundle = session.harnessVersionBundle;
const versions = await session.listHarnessVersions();

await session.switchHarnessVersion(versions.versions[0]!.manifest.version);
const references = session.createSkillReferences();

Use explicit harness options when composing a custom runtime:

const { session } = await createAgentSession({
  harnessStateStore,
  harnessVersionBundle,
  pinHarnessVersion: true,
});

pinHarnessVersion: true prevents later prompt admissions from following changes to the selected global pointer. Child sessions pin the parent's memory and evolved-skill snapshot. Custom ResourceLoader implementations must implement setEvolvedSkills(); getSkills() must return the unified installed/evolved catalog.

See Harness Evolution and Skills.

Context Files

import { createAgentSession, DefaultResourceLoader } from "@diffexai/diffex";

const loader = new DefaultResourceLoader({
  agentsFilesOverride: (current) => ({
    agentsFiles: [
      ...current.agentsFiles,
      { path: "/virtual/AGENTS.md", content: "# Guidelines\n\n- Be concise" },
    ],
  }),
});
await loader.reload();

const { session } = await createAgentSession({ resourceLoader: loader });

See examples/sdk/07-context-files.ts

Slash Commands

import {
  createAgentSession,
  DefaultResourceLoader,
  type PromptTemplate,
} from "@diffexai/diffex";

const customCommand: PromptTemplate = {
  name: "deploy",
  description: "Deploy the application",
  source: "(custom)",
  content: "# Deploy\n\n1. Build\n2. Test\n3. Deploy",
};

const loader = new DefaultResourceLoader({
  promptsOverride: (current) => ({
    prompts: [...current.prompts, customCommand],
    diagnostics: current.diagnostics,
  }),
});
await loader.reload();

const { session } = await createAgentSession({ resourceLoader: loader });

See examples/sdk/08-prompt-templates.ts

Session Management

Sessions use a tree structure with id/parentId linking, enabling in-place branching.

import {
  type CreateAgentSessionRuntimeFactory,
  createAgentSession,
  createAgentSessionFromServices,
  createAgentSessionRuntime,
  createAgentSessionServices,
  getAgentDir,
  SessionManager,
} from "@diffexai/diffex";

// In-memory (no persistence)
const { session } = await createAgentSession({
  sessionManager: SessionManager.inMemory(),
});

// New persistent session
const { session: persisted } = await createAgentSession({
  sessionManager: SessionManager.create(process.cwd()),
});

// Continue most recent
const { session: continued, modelFallbackMessage } = await createAgentSession({
  sessionManager: SessionManager.continueRecent(process.cwd()),
});
if (modelFallbackMessage) {
  console.log("Note:", modelFallbackMessage);
}

// Open specific file
const { session: opened } = await createAgentSession({
  sessionManager: SessionManager.open("/path/to/session.jsonl"),
});

// List sessions
const currentProjectSessions = await SessionManager.list(process.cwd());
const allSessions = await SessionManager.listAll(process.cwd());

// Session replacement API for /new, /resume, /fork, /clone, and import flows.
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
  const services = await createAgentSessionServices({ cwd });
  return {
    ...(await createAgentSessionFromServices({
      services,
      sessionManager,
      sessionStartEvent,
    })),
    services,
    diagnostics: services.diagnostics,
  };
};

const runtime = await createAgentSessionRuntime(createRuntime, {
  cwd: process.cwd(),
  agentDir: getAgentDir(),
  sessionManager: SessionManager.create(process.cwd()),
});

// Replace the active session with a fresh one
await runtime.newSession();

// Replace the active session with another saved session
await runtime.switchSession("/path/to/session.jsonl");

// Replace the active session with a fork from a specific user entry
await runtime.fork("entry-id");

// Clone the active path through a specific entry
await runtime.fork("entry-id", { position: "at" });

SessionManager tree API:

const sm = SessionManager.open("/path/to/session.jsonl");

// Session listing
const currentProjectSessions = await SessionManager.list(process.cwd());
const allSessions = await SessionManager.listAll(process.cwd());

// Tree traversal
const entries = sm.getEntries();        // All entries (excludes header)
const tree = sm.getTree();              // Full tree structure
const path = sm.getPath();              // Path from root to current leaf
const leaf = sm.getLeafEntry();         // Current leaf entry
const entry = sm.getEntry(id);          // Get entry by ID
const children = sm.getChildren(id);    // Direct children of entry

// Labels
const label = sm.getLabel(id);          // Get label for entry
sm.appendLabelChange(id, "checkpoint"); // Set label

// Branching
sm.branch(entryId);                     // Move leaf to earlier entry
sm.branchWithSummary(id, "Summary...");  // Branch with context summary
sm.createBranchedSession(leafId);       // Extract path to new file

See examples/sdk/11-sessions.ts and Session Format

Settings Management

import { createAgentSession, SettingsManager, SessionManager } from "@diffexai/diffex";

// Default: loads from files (global + project merged)
const { session } = await createAgentSession({
  settingsManager: SettingsManager.create(),
});

// With overrides
const settingsManager = SettingsManager.create();
settingsManager.applyOverrides({
  compaction: { enabled: false },
  retry: { enabled: true, maxRetries: 5 },
});
const { session } = await createAgentSession({ settingsManager });

// In-memory (no file I/O, for testing)
const { session } = await createAgentSession({
  settingsManager: SettingsManager.inMemory({ compaction: { enabled: false } }),
  sessionManager: SessionManager.inMemory(),
});

// Custom directories
const { session } = await createAgentSession({
  settingsManager: SettingsManager.create("/custom/cwd", "/custom/agent"),
});

Static factories:

  • SettingsManager.create(cwd?, agentDir?) - Load from files
  • SettingsManager.inMemory(settings?) - No file I/O

Project-specific settings:

Settings load from two locations and merge:

  1. Global: ~/.diffex/agent/settings.json
  2. Project: <cwd>/.diffex/settings.json

Project overrides global. Nested objects merge keys. Setters modify global settings by default.

Persistence and error handling semantics:

  • Settings getters/setters are synchronous for in-memory state.
  • Setters enqueue persistence writes asynchronously.
  • Call await settingsManager.flush() when you need a durability boundary (for example, before process exit or before asserting file contents in tests).
  • SettingsManager does not print settings I/O errors. Use settingsManager.drainErrors() and report them in your app layer.

See examples/sdk/10-settings.ts

ResourceLoader

Use DefaultResourceLoader to discover extensions, skills, prompts, themes, and context files.

import {
  DefaultResourceLoader,
  getAgentDir,
} from "@diffexai/diffex";

const loader = new DefaultResourceLoader({
  cwd,
  agentDir: getAgentDir(),
});
await loader.reload();

const extensions = loader.getExtensions();
const skillCatalog = loader.getSkills();
const prompts = loader.getPrompts();
const themes = loader.getThemes();
const contextFiles = loader.getAgentsFiles().agentsFiles;

getSkills() returns the session's unified SkillCatalog. Every skill includes its stable skillId, sourceKind, scope, captured content, contentDigest, and version in addition to its model-visible name, description, and location. Catalog diagnostics retain invalid, disabled, and colliding entries that were not admitted.

Use createSkill() for SDK-provided skills so the same identity and version rules apply:

import { createSkill, createSyntheticSourceInfo } from "@diffexai/diffex";

const skill = createSkill({
  name: "review",
  description: "Reviews repository changes.",
  filePath: "/virtual/review/SKILL.md",
  baseDir: "/virtual/review",
  sourceInfo: createSyntheticSourceInfo("/virtual/review/SKILL.md", { source: "sdk" }),
  content: "# Review\n\nReview the requested changes.",
});

Return Value

createAgentSession() returns:

interface CreateAgentSessionResult {
  // The session
  session: AgentSession;
  
  // Extensions result (for runner setup)
  extensionsResult: LoadExtensionsResult;
  
  // Warning if session model couldn't be restored
  modelFallbackMessage?: string;
}

interface LoadExtensionsResult {
  extensions: Extension[];
  errors: Array<{ path: string; error: string }>;
  runtime: ExtensionRuntime;
}

Complete Example

import { getModel } from "@diffexai/diffex-ai";
import { Type } from "typebox";
import {
  createAgentSession,
  DefaultResourceLoader,
  defineTool,
  ModelRuntime,
  SessionManager,
  SettingsManager,
} from "@diffexai/diffex";

const modelRuntime = await ModelRuntime.create({
  authPath: "/custom/agent/auth.json",
  modelsPath: "/custom/agent/models.json",
});
if (process.env.MY_KEY) {
  await modelRuntime.setRuntimeApiKey("anthropic", process.env.MY_KEY);
}

// Inline tool
const statusTool = defineTool({
  name: "status",
  label: "Status",
  description: "Get system status",
  parameters: Type.Object({}),
  execute: async () => ({
    content: [{ type: "text", text: `Uptime: ${process.uptime()}s` }],
    details: {},
  }),
});

const model = getModel("anthropic", "claude-opus-4-5");
if (!model) throw new Error("Model not found");

// In-memory settings with overrides
const settingsManager = SettingsManager.inMemory({
  compaction: { enabled: false },
  retry: { enabled: true, maxRetries: 2 },
});

const loader = new DefaultResourceLoader({
  cwd: process.cwd(),
  agentDir: "/custom/agent",
  settingsManager,
  systemPromptOverride: () => "You are a minimal assistant. Be concise.",
});
await loader.reload();

const { session } = await createAgentSession({
  cwd: process.cwd(),
  agentDir: "/custom/agent",

  model,
  thinkingLevel: "off",
  modelRuntime,

  tools: ["read", "bash", "status"],
  customTools: [statusTool],
  resourceLoader: loader,

  sessionManager: SessionManager.inMemory(),
  settingsManager,
});

session.subscribe((event) => {
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
    process.stdout.write(event.assistantMessageEvent.delta);
  }
});

await session.prompt("Get status and list files.");

Run Modes

The SDK exports run mode utilities for building custom interfaces on top of createAgentSession():

InteractiveMode

Full TUI interactive mode with editor, chat history, and all built-in commands:

import {
  type CreateAgentSessionRuntimeFactory,
  createAgentSessionFromServices,
  createAgentSessionRuntime,
  createAgentSessionServices,
  getAgentDir,
  InteractiveMode,
  SessionManager,
} from "@diffexai/diffex";

const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
  const services = await createAgentSessionServices({ cwd });
  return {
    ...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
    services,
    diagnostics: services.diagnostics,
  };
};
const runtime = await createAgentSessionRuntime(createRuntime, {
  cwd: process.cwd(),
  agentDir: getAgentDir(),
  sessionManager: SessionManager.create(process.cwd()),
});

const mode = new InteractiveMode(runtime, {
  migratedProviders: [],
  modelFallbackMessage: undefined,
  initialMessage: "Hello",
  initialImages: [],
  initialMessages: [],
});

await mode.run();

runPrintMode

Single-shot mode: send prompts, output result, exit:

import {
  type CreateAgentSessionRuntimeFactory,
  createAgentSessionFromServices,
  createAgentSessionRuntime,
  createAgentSessionServices,
  getAgentDir,
  runPrintMode,
  SessionManager,
} from "@diffexai/diffex";

const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
  const services = await createAgentSessionServices({ cwd });
  return {
    ...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
    services,
    diagnostics: services.diagnostics,
  };
};
const runtime = await createAgentSessionRuntime(createRuntime, {
  cwd: process.cwd(),
  agentDir: getAgentDir(),
  sessionManager: SessionManager.create(process.cwd()),
});

await runPrintMode(runtime, {
  mode: "text",
  initialMessage: "Hello",
  initialImages: [],
  messages: ["Follow up"],
});

runRpcMode

JSON-RPC mode for subprocess integration:

import {
  type CreateAgentSessionRuntimeFactory,
  createAgentSessionFromServices,
  createAgentSessionRuntime,
  createAgentSessionServices,
  getAgentDir,
  runRpcMode,
  SessionManager,
} from "@diffexai/diffex";

const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
  const services = await createAgentSessionServices({ cwd });
  return {
    ...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
    services,
    diagnostics: services.diagnostics,
  };
};
const runtime = await createAgentSessionRuntime(createRuntime, {
  cwd: process.cwd(),
  agentDir: getAgentDir(),
  sessionManager: SessionManager.create(process.cwd()),
});

await runRpcMode(runtime);

See RPC documentation for the JSON protocol.

RPC Mode Alternative

For subprocess-based integration without building with the SDK, use the CLI directly:

diffex --mode rpc --no-session

See RPC documentation for the JSON protocol.

The SDK is preferred when:

  • You want type safety
  • You're in the same Node.js process
  • You need direct access to agent state
  • You want to customize tools/extensions programmatically

RPC mode is preferred when:

  • You're integrating from another language
  • You want process isolation
  • You're building a language-agnostic client

Exports

The main entry point exports:

// Factory
createAgentSession
createAgentSessionRuntime
AgentSessionRuntime

// Auth and Models
ModelRuntime // implements diffex-ai Models and owns credential storage
ModelRegistry // synchronous extension compatibility facade
CredentialSynchronizationError
resolveCliModel
resolveModelScopeWithDiagnostics

// Resource loading
DefaultResourceLoader
type ResourceLoader
createEventBus

// Constants and helpers
CONFIG_DIR_NAME
defineTool
getAgentDir
getPackageDir
getReadmePath
getDocsPath
getExamplesPath

// Session management
SessionManager
SettingsManager

// Tool factories
createCodingTools
createReadOnlyTools
createReadTool, createBashTool, createEditTool, createWriteTool, createUpdatePlanTool
createGrepTool, createFindTool, createLsTool

// Types
type CreateAgentSessionOptions
type CreateAgentSessionResult
type ExtensionFactory
type InlineExtension
type ExtensionAPI
type ToolDefinition
type Skill
type PromptTemplate
type Tool

For extension types, see extensions.md for the full API.