npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vietor/agent-core

v0.8.13

Published

Lightweight AI agent framework — session orchestration, tool system, MCP client, skill loader

Readme

@vietor/agent-core

Lightweight AI agent framework — session orchestration, tool system, MCP client, skill loader.

npm install @vietor/agent-core

Requires Node.js ≥ 22 (ESM only).

Import paths

The package exposes two entry points:

| Import | Contents | |---|---| | @vietor/agent-core | createSession, the Session API, tools, skills, MCP, LLM config, shared types | | @vietor/agent-core/util | Framework utilities: async helpers, text formatting, subprocess, file/HTML/net helpers, constants | | @vietor/agent-core/package.json | The package manifest, for tooling that probes the installed version |

import { createSession, type Tool } from "@vietor/agent-core";
import { runProcess, formatDuration } from "@vietor/agent-core/util";

Quick Start

import { createSession } from "@vietor/agent-core";

const session = await createSession({
  systemPrompt: "You are a helpful assistant.",
  llm: {
    baseUrl: "https://api.deepseek.com/v1",
    apiKey: "your-api-key",
    model: "deepseek-v4-flash",
  },
  builtInTools: { askUser: true },
});

session.onEvent((e) => {
  if (e.type === "assistant_delta") process.stdout.write(e.text);
  else if (e.type === "tool_start") console.log(`\n[tool] ${e.name} ${e.argsSummary}`);
  else if (e.type === "tool_end" && e.isError) console.log(`\n[tool failed] ${e.resultSummary}`);
  else if (e.type === "error") console.error(`\n[error] ${e.text}`);
  else if (e.type === "question") {
    // Render e.questions, then answer via session.submitAnswer(e.id, answers) — see "Asking the user"
  }
});

process.on("SIGINT", async () => {
  if (session.running) {
    session.abort();                        // the run settles with status "aborted"; exit on the next signal
  } else {
    await session.save();                   // no-op without sessionDir
    session.dispose();                      // kills MCP server processes
    process.exit(0);
  }
});

const result = await session.prompt("What files are in the current directory?");
console.log(result.reply);                // final assistant reply

Two facts that shape a host's startup:

  • MCP servers connect in the background. createSession returns before they are up; their tools appear as each server connects (session.mcpServers tracks pending/connected/failed). await session.connectMCP(servers) yourself when you need them ready before the first prompt.
  • One run at a time. Calling prompt() (or compact()/runSkill()/clear()) while a run is in progress throws SessionBusyError — check session.running or catch it. Core does not queue.

Hosts own the terminal: rendering, key handling, and answering questions are host concerns. INTEGRATION.md documents the full contract (events, questions, interrupts, cleanup) and examples/terminal-embed in the repository is a runnable reference built on nothing but node: and this package.


API Reference — @vietor/agent-core

createSession

createSession(options: SessionOptions): Promise<Session>

Factory that wires together the LLM client, tool registry, MCP servers, and skills into a ready-to-use Session instance. The MCP servers listed in mcpServers connect in the background — they do not delay the returned Session, and their tools appear once a server is up.

import { createSession, LLMConfigSchema } from "@vietor/agent-core";

const session = await createSession({
  systemPrompt: "You are a helpful assistant.",
  llm: LLMConfigSchema.parse({
    baseUrl: "https://api.deepseek.com/v1",
    apiKey: "your-api-key",
    model: "deepseek-v4-flash",
    thinkingEffort: "high",
    backend: "completions",
    maxInputTokens: 1_000_000,
  }),
  tools: [myCustomTool],
  skills: tryLoadSkills("./skills") ?? [],
  mcpServers: {
    filesystem: { type: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "."] },
  },
  builtInTools: { askUser: true },
  clientInfo: { name: "my-app", version: "1.0.0" },
});

SessionOptions

| Property | Type | Default | Description | |---|---|---|---| | systemPrompt | string | (required) | System prompt for the LLM. createSession appends the environment block and the tool-use guidelines after it (plus the skill list when skills are loaded). | | llm | LLMConfig \| LLMClient | (required) | LLM endpoint config (OpenAI-compatible or Anthropic; see backend), or your own LLMClient — an injected client is used as-is (see Custom LLM clients). Only baseUrl, apiKey, and model are required on a config; thinkingEffort, backend, vision, maxInputTokens, and maxOutputTokens default to "high", "completions", true, 1_000_000, and 128_000, the last two with minimums of 128_000 and 48_000. | | cwd | string | process.cwd() | Working directory used by tools (e.g. path-based tools). | | tools | Tool[] | undefined | Additional tools registered alongside built-ins. | | skills | Skill[] | undefined | Skills loaded from SKILL.md files; invoked via the built-in Skill tool or via session.runSkill() (hosts may map them to slash commands). | | mcpServers | Record<string, MCPServerConfig> | undefined | MCP servers to connect in the background after startup. | | builtInTools | BuiltinToolsOptions \| false | (7 core tools enabled; interactive tools off) | readOnly: true registers only the read-only core tools (Read/Glob/Grep/WebFetch); askUser/todoWrite/subAgent enable interactive tools (all off by default); false to disable all built-in tools. | | clientInfo | { name: string; version: string } | { name: "agent-core", version: "0.0.0" } | Client identity sent to MCP servers. | | sessionId | string | UUID() | Unique session identifier. Must be a single path segment: createSession throws when it is empty, ., .., or contains a path separator, since it names this session's files inside sessionDir and scratchDir. | | maxTurns | number | 50 | Tool-calling turns allowed per prompt. Once the last 20% is left the run warns the model in-band, and the turn after the budget is spent is reserved for the final answer with tools disabled. | | stallThreshold | number | 3 | Stall tolerance: consecutive identical tool-call sets, or consecutive text-only responses while todos are incomplete, before the run is treated as stalled. | | maxParallelToolCalls | number | 10 | Maximum number of tool calls executed concurrently in one turn. | | maxSubAgentDepth | number | 3 | How deep SubAgent delegation may nest. A sub-agent run by the session is depth 1; an agent at maxSubAgentDepth gets no SubAgent tool and is told it cannot spawn one. 1 disables nesting entirely. | | maxConcurrentSubAgents | number | 4 | How many sub-agents may run at once per session. When the session's own turn issues more SubAgent calls than the budget, the excess wait for a free slot and run in the same turn, in call order — but once one of that turn's sub-agents ends with a status other than ok, the calls still waiting are skipped with an error result instead of starting. Nested delegations (issued by a sub-agent) are refused with an error instead of waiting, since a parent run holds its slot while it awaits its children. The effective fan-out is also bounded by maxParallelToolCalls, which caps the calls in flight in a single turn. | | maxSubAgentTurns | number | maxTurns | Tool-calling turns allowed to each sub-agent run, at every nesting level. Defaults to the session's own maxTurns, so a session coordinating a large fan-out can give its workers a longer loop than it keeps for itself. | | sessionDir | string | undefined | Directory for the session's JSONL file, written as <sessionDir>/<sessionId>.jsonl. When unset, core keeps no storage. | | importPath | string | undefined | Path to another session file whose content seeds this session's history instead of the session's own file. Only messages and todos are taken — the source's session record and any records core doesn't know are ignored, nothing is copied, and this session still writes its own <sessionDir>/<sessionId>.jsonl with its own creation time. | | scratchDir | string | undefined | Directory for the session's working files: the model's notes at <scratchDir>/notes.md, and oversized tool output saved in full as <scratchDir>/<tool>.<uuid>.txt — structured output is summarized as a shape rather than inlined, and Read pages the original file instead of saving a copy. createSession clears the directory at construction, so nothing in it carries over into a new session, resumed or not. When unset, oversized output is truncated without being saved. |

maxTurns, stallThreshold, maxParallelToolCalls, maxSubAgentDepth, maxConcurrentSubAgents, and maxSubAgentTurns must be positive integers; createSession throws at construction otherwise.

The auto-compaction threshold is not configurable — it's derived internally as 75% of llm.maxInputTokens and exposed via session.contextLimit. Compaction replaces the history with a summary plus the most recent messages verbatim (up to 15% of contextLimit), so the latest exchange survives without being re-read. When scratchDir is set, the prompt also directs the model to keep a running notes file at <scratchDir>/notes.md and to re-read it after a compaction instead of re-exploring; the turn after a compaction carries an in-band reminder naming that file.

SYSTEM_PROMPT_BOUNDARY

SYSTEM_PROMPT_BOUNDARY: string

A constant separator that createSession appends between the user-provided systemPrompt and the auto-generated sections (environment, tool-use guidelines, and the skill list). Also exported so callers can use it when composing their own system prompt from multiple segments:

import { SYSTEM_PROMPT_BOUNDARY } from "@vietor/agent-core";

const systemPrompt = [
  coreInstructions,
  contextRules,
].join(SYSTEM_PROMPT_BOUNDARY);

Session

The main session object. Create one via createSession() — it wires the LLM client, tool registry, and MCP servers, and connects the servers listed in mcpServers in the background:

const session = await createSession({ systemPrompt, llm });

Running prompts

| Method | Description | |---|---| | prompt(text: string): Promise<PromptResult> | Submit a user message and run the agent loop (LLM → tool calls → LLM) until a final answer or error. Returns a PromptResult with the run status and the final assistant reply. |

Managing conversation

A session built with sessionDir resumes its own <sessionId>.jsonl at construction — that file is the only way state goes in or out, so there is no separate import call (see SessionState below).

| Method | Description | |---|---| | clear(): void | Reset the conversation and log. | | save(): Promise<void> | Flush the current state to disk. Resolves immediately when sessionDir is not configured. | | compact(): Promise<RunStatus> | Ask the LLM to summarize the conversation so far, replacing history with a summary message followed by the retained recent messages. Runs through the run loop — streams the summary and can be aborted via abort(). | | abort(): void | Abort the current prompt or compact, cancel pending tool calls, and dismiss unanswered user questions. | | submitAnswer(id: string, answers: AskAnswer[]): void | Supply answers to a pending user question group (from the built-in AskUser tool), one entry per question; multi-select answers are string[], skipped ones "". | | pendingQuestion: Extract<TimelineEvent, { type: "question" }> \| undefined | (getter) The most recent unanswered question group, or undefined if none are pending. |

Asking the user

The built-in AskUser tool takes 1-4 questions in one call; each question has an optional header (≤ 12 chars), 2-4 options with optional descriptions, and an optional multiSelect flag:

{
  questions: [
    { header: "Deploy", question: "Which environment?", options: [{ label: "prod" }, { label: "staging" }], multiSelect: false },
    { question: "Notify on?", options: [{ label: "email" }, { label: "slack" }], multiSelect: true },
  ],
}

The session emits one question timeline entry (also delivered via onEvent) — each item is an AskedQuestion (an AskQuestion plus an answer field, null while pending) — and pauses the run until answered:

{ type: "question", id: "q1", questions: [{ question: "Which environment?", options: [...], multiSelect: false, answer: null }, ...] }

The host renders the questions and supplies one answer per question via submitAnswer — string for single-select, string[] for multi-select, "" to skip:

session.submitAnswer("q1", ["prod", ["email", "slack"]]);

The tool result fed back to the LLM is JSON keyed by question text; multi-select values are arrays of selected labels, skipped questions "":

{ "Which environment?": "prod", "Notify on?": ["email", "slack"] }

abort() (or dispose()) resolves any pending question group with all-"" answers and stops the run.

Events

| Method | Description | |---|---| | onEvent(listener: (e: SessionEvent) => void): () => void | Subscribe to structured incremental events (streaming deltas, tool calls, errors, questions, run state). Supports multiple listeners; returns an unsubscribe function. |

SessionEvent

A discriminated union emitted as the session runs — the union of the standalone TimelineEvent and StreamEvent types:

type SessionEvent = TimelineEvent | StreamEvent;

Timeline events are also stored in session.getSnapshot().timeline:

type TimelineEvent =
  | { type: "user"; text: string }
  | { type: "skill"; name: string }
  | { type: "assistant"; text: string }
  | { type: "tool"; id: string; name: string; argsSummary: string; result: string | null; isError?: boolean; resultSummary?: string }
  | { type: "retry"; attempt: number; max: number; reason: string }
  | { type: "error"; text: string }
  | { type: "interrupted" }
  | { type: "question"; id: string; questions: AskedQuestion[] }
  | { type: "notice"; text: string };

Streaming events are transient — never stored in the timeline, delivered only to onEvent listeners:

type StreamEvent =
  | { type: "assistant_delta"; text: string }
  | { type: "thinking_delta"; text: string }
  | { type: "thinking_cleared" }
  | { type: "tool_start"; id: string; name: string; argsSummary: string }
  | { type: "tool_end"; id: string; result: string; isError?: boolean; resultSummary?: string }
  | { type: "todos_changed"; todos: Todo[] }
  | { type: "mcp_changed" }
  | { type: "sub_agent_event"; toolCallId: string; event: SessionEvent }
  | ({ type: "run_metrics" } & RunMetrics);

| Type | Emitted when | In timeline | |---|---|---| | user | User submits a prompt (prompt). | ✓ | | skill | A skill is invoked. | ✓ | | assistant_delta | A streaming token delta from the LLM. | — | | thinking_delta | A streaming thinking token delta (extended thinking). | — | | thinking_cleared | The accumulated thinking text is cleared (e.g. on new tool round). | — | | assistant | A text response segment is flushed (on tool call or completion). | ✓ | | tool_start | A tool call starts. | ✓ (stored as tool) | | tool_end | A tool call finishes. | — (merged into its tool entry) | | todos_changed | The todo list changed: TodoWrite, clear(), or the run-end clear of an all-completed list. Payload is the new list. | — | | mcp_changed | MCP server state changed: a connectMCP call settled, or a connected server closed. The tool registry — and so contextTokens — may have changed. | — | | sub_agent_event | An event from a running sub-agent, wrapped with the id of the parent SubAgent tool call. | — | | retry | The LLM client retries after a transient API error. | ✓ | | error | An error occurred. | ✓ | | interrupted | The current run was aborted. | ✓ | | question | The AskUser tool poses a group of 1-4 questions. | ✓ | | notice | session.addNotice() is called, or the run auto-compacts context. | ✓ | | run_metrics | Run metrics change: at run start, every second, and at run end (running: false). | — |

todos_changed is the push counterpart of getSnapshot().todos: emitted on every mutation (including TodoWrite's normalizations), carrying the same list the view will report. subscribe/getSnapshot keep working unchanged; the event is for consumers that render from the stream alone.

mcp_changed is emitted once per connectMCP call after every configured server has settled, and again when a connected server closes and its tools are unregistered. Timeline and getSnapshot() are untouched — it exists so hosts can refresh MCP-derived state (e.g. contextTokens, mcpServers) instead of polling, since createSession connects mcpServers in the background.

Sub-agent events. A running sub-agent's own events (assistant_delta, thinking_delta, tool_start/tool_end, retry, error, interrupted, notice) are re-emitted on the session's onEvent as sub_agent_event, each tagged with the toolCallId of the SubAgent call that started it — so concurrent delegations can be attributed exactly. A nested delegation (a sub-agent spawning a sub-agent) is tagged with the same session-level call id that began the tree. These events never enter the session timeline and never touch the parent's stream buffer — accumulate them per toolCallId (keyed from the SubAgent tool_start.id) if you render a live view of each delegation. One difference from the top-level stream: a nested stream carries no thinking_cleared (flushing is the Session layer's job), so drop accumulated nested thinking when the next nested assistant_delta, tool_start, or error arrives, or when the parent delegation's tool_end closes it.

Note: onEvent is the primary stream for network/remote consumers (multi-subscriber, incremental). For local React useSyncExternalStore view invalidation use subscribe + getSnapshot.

RunMetrics

interface RunMetrics {
  running: boolean;        // whether a prompt is in progress
  elapsed: number;         // seconds since the current prompt started
  thinkingElapsed: number; // seconds before the first assistant text token (incl. thinking/tools)
  replyElapsed: number;    // seconds after the first assistant text token (incl. later tool rounds)
  cacheInputTokens: number; // cumulative cached input (prompt) tokens for the current run
  missInputTokens: number; // cumulative non-cached input (prompt) tokens for the current run
  outputTokens: number;    // cumulative output (completion) tokens for the current run
}

INITIAL_RUN_METRICS: RunMetrics is the all-zero, not-running initial value.

Skills & notices

The command system lives in host code. Core exposes the primitives hosts build on:

| Method | Description | |---|---| | runSkill(name: string): Promise<boolean> | Run a skill by name through the agent loop. Returns false (no error emitted) if the name is unknown; throws SessionBusyError if a run is in progress. | | addNotice(text: string): void | Append a notice entry to the timeline and emit a notice event. | | addError(text: string): void | Append an error entry to the timeline and emit an error event. | | skills: readonly Skill[] | (getter) All loaded skills. |

State accessors

| Property | Type | Description | |---|---|---| | model | string | The LLM model name (e.g. "deepseek-v4-flash"). | | thinkingEffort | "high" \| "max" | The configured thinking effort. | | contextLimit | number | Estimated-token threshold that triggers auto-compaction. | | contextTokens | number | Estimated token count of the current request: conversation plus tool schemas. | | mcpServers | readonly MCPServerInfo[] | Status and tool list of connected MCP servers. | | cwd | string | The resolved working directory used by tools. | | sessionId | string | The unique session identifier. | | filePath | string \| undefined | Path of this session's JSONL file, or undefined when sessionDir is not configured. | | running | boolean | Whether a prompt/compact is in progress. Check before issuing a driver call (see Reentrancy). |

Reentrancy

A Session runs one prompt/compact at a time. While a run is in progress, calling a driver method throws SessionBusyError so a host can map it to an HTTP 409:

| Driver method | Behavior when busy | |---|---| | prompt, compact, runSkill, clear | Throws SessionBusyError. |

These remain callable during a run (they are inputs to the running loop, or read-only/teardown):

| Method | Behavior when busy | |---|---| | abort, submitAnswer | Allowed - control the running loop. | | onEvent, subscribe, getSnapshot, pendingQuestion, save, dispose, accessors | Allowed. |

import { SessionBusyError } from "@vietor/agent-core";

if (!session.running) {
  try {
    await session.prompt(text);
  } catch (e) {
    if (e instanceof SessionBusyError) /* -> HTTP 409 */;
  }
}

The run_metrics event (running: boolean) also signals run start/end for stream consumers.

Snapshot subscription

| Method | Description | |---|---| | subscribe(listener: () => void): () => void | Subscribe to timeline or todo changes; returns an unsubscribe function. | | getSnapshot(): SessionView | Current session snapshot ({ timeline, todos }); the reference stays stable until the next change. Designed for useSyncExternalStore. |

Cleanup

| Method | Description | |---|---| | dispose(): void | Kill all MCP server processes and clean up. |


Types

SessionView

The snapshot returned by session.getSnapshot().

interface SessionView {
  timeline: readonly TimelineEvent[];
  todos: readonly Todo[];
}

PromptResult

Returned by session.prompt().

interface PromptResult {
  status: RunStatus;
  reply: string;
  error?: string;
}

status indicates how the run ended; reply is the final assistant text (may be partial or empty when status !== "ok"). error is present only when the run ended with status: "error", carrying the same message as the emitted error event — so a caller that does not subscribe to onEvent still sees why the run failed. Retries and other in-band errors are still delivered only via events.

PendingQuestion

The question timeline entry returned by session.pendingQuestion.

type PendingQuestion = Extract<TimelineEvent, { type: "question" }>;

RunStatus

type RunStatus = "ok" | "aborted" | "error" | "stalled" | "maxTurns";

| Status | Meaning | |---|---| | ok | The run completed with a final assistant reply. | | aborted | The run was aborted via abort(). | | error | The run ended due to an LLM/API error. | | stalled | The agent stalled past stallThreshold: repeated identical tool calls, or repeated text-only responses while todos are incomplete. | | maxTurns | The model called a tool on the reserved final turn. The conversation is intact, so a new prompt resumes with a fresh budget. |

Also returned by session.compact() ("ok" on success, "aborted" if aborted, "error" on failure).

Timeline

SessionView.timeline is readonly TimelineEvent[] — a standalone union of nine entry types (user, skill, assistant, tool, retry, error, interrupted, question, notice), defined independently of the event stream. Transient stream events (assistant_delta, thinking_delta, thinking_cleared, tool_start/tool_end, run_metrics) are never stored; tool_start opens a tool entry with result: null and tool_end is merged into it.

tool and question entries carry lifecycle state as pending fields, set null while outstanding and replaced on completion:

| Field | Meaning | |---|---| | result: string \| null (tool) | null while the tool is running; the result text once tool_end arrives, or "aborted" if the run was interrupted. | | isError?: boolean / resultSummary?: string (tool) | Set when the tool ended with an error / a condensed summary of the result. | | questions: AskedQuestion[] (question) | The question group; each item is an AskQuestion (header?, question, options: {label, description?}[], multiSelect) with an answer field: string for single-select, string[] for multi-select, "" when skipped; null until answered (via submitAnswer or abort). |

SessionMessage

The internal message format exchanged with the agent, as contained in SessionState.messages.

type SessionMessage =
  | { role: "system"; content: string }
  | { role: "user"; content: string }
  | { role: "skill"; name: string; content: string }
  | LLMAssistantMessage
  | { role: "tool"; tool_call_id: string; content: string; resultSummary?: string; isError?: boolean };

// LLMAssistantMessage includes optional tool_calls[] for function-calling

LLMConfig

LLMConfigSchema is the single source of truth (hosts compose it into their own config schema); LLMConfig is its input shape and ResolvedLLMConfig its parsed shape, with every default applied:

const LLMConfigSchema = z.object({
  baseUrl: z.string(),            // API endpoint (e.g. "https://api.deepseek.com/v1" or "https://api.anthropic.com") — required
  apiKey: z.string(),             // API key — required
  model: z.string(),              // Model name (e.g. "deepseek-v4-flash" or "claude-sonnet-5") — required
  headers: z.record(z.string(), z.string()).optional(),  // Extra headers on every request (gateway routing, auth) — sent as SDK default headers, so the SDK's own computed headers (authorization etc.) still win per request
  thinkingEffort: z.enum(["high", "max"]).default("high"),  // Thinking depth; "high" for standard tasks, "max" for deeper thinking on complex tasks
  backend: z.enum(["completions", "anthropic", "responses"]).default("completions"),  // Wire protocol; "completions" (OpenAI Chat Completions), "anthropic" (Anthropic Messages API via the official SDK), or "responses" (OpenAI Responses API via the official SDK)
  vision: z.boolean().default(true),  // Set false for text-only endpoints: Read then returns an image description without attaching image data, instead of failing the request on every turn
  maxInputTokens: z.int().min(128_000).default(1_000_000),  // Context window in tokens (min 128_000); 75% of it is used as the auto-compaction threshold
  maxOutputTokens: z.int().min(48_000).default(128_000),    // Max output tokens per request (min 48_000), capped by the model's output limit
});

type LLMConfig = z.input<typeof LLMConfigSchema>;

type ResolvedLLMConfig = z.infer<typeof LLMConfigSchema>;

type LLMThinkingEffort = ResolvedLLMConfig["thinkingEffort"];

type LLMBackend = ResolvedLLMConfig["backend"];

createSession and createLLM take LLMConfig, where only baseUrl, apiKey, and model are required. The schema is applied at the boundary, so every field is set on the ResolvedLLMConfig that reaches the client and its adapters. z is re-exported from the package root, so hosts composing these schemas into their own do not need a zod dependency of their own. (An injected LLMClient bypasses the schema — see Custom LLM clients.)

backend selects the request/response protocol the client speaks:

  • "completions" - OpenAI Chat Completions compatible endpoint. thinkingEffort is sent as reasoning_effort; maxOutputTokens is sent as max_tokens.
  • "anthropic" - Anthropic Messages API (via @anthropic-ai/sdk). Point baseUrl at an Anthropic-compatible endpoint and model at a Claude model. maxOutputTokens is sent as max_tokens; thinkingEffort enables extended thinking ("high" = 16k token budget, "max" = 32k, both capped by maxOutputTokens); thinking blocks are preserved across tool-use turns as required by the API.
  • "responses" - OpenAI Responses API (via openai SDK). Tool results round-trip as function_call/function_call_output items; maxOutputTokens is sent as max_output_tokens; thinkingEffort is sent as reasoning.effort, and reasoning summaries are streamed via reasoning.summary_text.

Custom LLM clients

SessionOptions.llm also accepts your own LLMClient implementation — a gateway wrapper, an in-process model, or a test double:

interface LLMClient {
  readonly model: string;
  readonly thinkingEffort: LLMThinkingEffort;
  readonly maxInputTokens: number;   // drives session.contextLimit
  readonly maxOutputTokens: number;
  readonly vision?: boolean;         // false = text-only model; absent means the model accepts image input
  chat(opts: ChatOptions): Promise<LLMAssistantMessage>;
}

createSession tells the two apart with isLLMClient(llm) (a client has a chat function) and then uses the client as-is — nothing is re-validated or rebuilt. Consequences: maxInputTokens/maxOutputTokens must be truthful, because contextLimit is derived from them (floor(min(maxInputTokens × 0.75, maxInputTokens − maxOutputTokens))); and retries/timeouts are entirely the client's responsibility (the retry stack lives in createLLM). A client can reuse core's retry handling by implementing the LLMAdapter shape (an LLMClient minus chat, plus stream) and wrapping it with withRetryChat(adapter).

A minimal chat implementation must:

  • honor opts.signal (abort must reject the pending call);
  • stream text via opts.onDelta and thinking via opts.onThinking;
  • report token usage via opts.onUsage (feeds run_metrics and the session's token accounting);
  • call opts.onToolCall() when the response contains tool calls (used to decide whether a transient failure is safe to retry);
  • accept thinking: false (compaction turns), toolChoice: "none" (the reserved final turn), and cachePrefixLen (may be ignored);
  • return a complete LLMAssistantMessage (content, optional tool_calls, optional thinking).

SessionState

The complete session state — what core writes to a session file. The shape stays inside core: a host checks a candidate file with isSessionFile(), it is never read back as an object.

interface SessionState {
  messages: SessionMessage[];
  todos: Todo[];
}

With sessionDir set, core persists the state itself as JSONL at <sessionDir>/<sessionId>.jsonl (a leading session record, one record per message, plus a trailing todo record) and keeps it current: a save runs at the end of every run — awaited, so the file is on disk before prompt()/compact()/runSkill() resolves — and on clear(). Saves are serialized, and each one rewrites the whole file through a staged temp file + rename, so the file is never half-written and no save can leave a stale copy of a message behind; a save whose content is unchanged is skipped. A failed save emits an error event without failing the run.

createSession also sweeps sessionDir — dropping files past the retention age or over the total size cap while keeping the current session's own <sessionId>.jsonl — and clears scratchDir outright, since scratch files never carry over into a new session; the run loop re-sweeps the scratch directory at every run boundary, keeping its notes.md.

With sessionDir set, the session file is the interface: constructing a session whose <sessionId>.jsonl already exists resumes it — the whole of --continue/--resume is picking that id, and --import seeds a new session from another file's content (importPath) — while save() writes it back. Without sessionDir, core never touches the filesystem. State crosses the boundary as a session file: a host checks a candidate with isSessionFile() and hands one over by putting it at <sessionDir>/<sessionId>.jsonl; the JSONL format itself stays internal.

Session files are also reachable without a Session:

| Function | Description | |---|---| | isSessionExists(dir, sessionId) | Whether dir holds a session file for that id. The path itself never leaves core. | | isSessionFile(path) | Whether path is a session file: it exists and its leading session record says so. The contents are never handed back. | | listSessions(dir) | Every session in dir as SessionMeta ({ id, title?, createdAt, updatedAt }), most recently updated first. The title is the first user message, summarized; a session that never had one is untitled. createdAt comes from the file's leading session record, falling back to its birth time for files written before that record existed. |

Todo

interface Todo {
  content: string;
  status: TodoStatus;
}

type TodoStatus = "pending" | "inProgress" | "completed";

Tool System

Tool

interface Tool {
  name: string;
  agentLevel?: AgentLevel;   // 0 = never granted to sub-agents; 1 = read-only sub-agents; 2 = writable sub-agents only
  concurrencySafe?: boolean; // may run alongside other tool calls in the same turn
  description: string;
  parameters: Record<string, unknown>;   // JSON Schema object
  argSummaryKeys?: string[];                // parameter keys used for display summary
  summarizeArgs?: (args: Record<string, unknown>) => string; // custom summary function
  summarizeResult?(result: ToolResult): string; // result summary for timeline display
  execute(args: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>;
}
  • agentLevel (optional, default 0) declares which sub-agents may use the tool: 0 = never granted to any sub-agent; 1 = granted to read-only sub-agents ("explore"/"plan"), which therefore also reaches writable ones; 2 = granted only to writable sub-agents ("general"). Tools with agentLevel: 1 are what builtInTools: { readOnly: true } registers (Read/Glob/Grep/WebFetch). If you previously marked a custom tool readOnly: true to expose it to sub-agents, set agentLevel: 1; use agentLevel: 2 for custom writable tools meant for the general sub-agent only. AskUser/Skill/TodoWrite are session-scoped and stay at 0; SubAgent is never granted by level either — a sub-agent below the nesting cap gets a SubAgent tool of its own, so delegation nests up to maxSubAgentDepth levels below the session.
  • concurrencySafe (optional, default false) declares whether the tool may run concurrently with other tool calls from the same turn. A turn's calls are split into consecutive runs: a run of concurrencySafe calls executes together (up to maxParallelToolCalls), and every other call runs on its own, with nothing else in flight. The default is fail-closed, so custom and MCP tools run one at a time until you opt them in; declare it only for tools that read without mutating state. Marked by default: Read, Glob, Grep, WebFetch, and SubAgent.
  • parameters is passed to the LLM as a JSON Schema to describe the tool's arguments.
  • When the LLM calls a tool, execute receives the parsed arguments and a context object.
  • execute returns a ToolResult ({ content, isError?, images? }). Expected failures return toolError(...) (exported from the package); unexpected errors may throw and are wrapped by the registry.
  • argSummaryKeys / summarizeArgs control what appears in the tool log entry's argsSummary field.
  • summarizeResult (optional) returns a short result summary for timeline display. Called after execution with the result; the registry prefixes the wall-clock duration. Falls back to a default summary (byte/line count) when not defined.

ToolContext

interface ToolContext {
  signal?: AbortSignal;  // abort signal for the current run
  cwd: string;           // resolved working directory for path-based tools
  toolCallId?: string;   // the LLM's id for this tool call; SubAgent uses it to tag sub_agent_event
  vision?: boolean;      // false when the configured client declares vision: false; Read then skips attaching image data
}

ToolResult

interface ToolResult {
  content: string;
  isError?: boolean;
  images?: ImagePart[];
}

images side-channels image parts (currently only produced by Read) alongside the textual content: events, summaries, and truncation operate on content alone, and each backend lowers images into its own wire format. The session estimates image tokens from the parsed header dimensions (pixel area over 750, scaling the longest edge to 1568), falling back to a flat 1600 when the header cannot be parsed.

ImagePart

interface ImagePart {
  mimeType: "image/png" | "image/jpeg" | "image/gif" | "image/webp";
  data: string;   // base64, no data-URL prefix
}

ToolSchema

interface ToolSchema {
  type: "function";
  function: {
    name: string;
    description: string;
    parameters: Record<string, unknown>;
  };
}

The format sent to the LLM's tools parameter. Generated automatically from registered Tool objects.

Built-in tools

Core tools (registered by default; builtInTools: { readOnly: true } registers only the read-only subset):

| Tool | Description | |---|---| | Read (read-only) | Read files with line numbers; png, jpeg, gif, and webp files are returned as image content the model can see (with vision: false, a description without the image data). | | Glob (read-only) | File listing by glob pattern. A capped listing reports the total match count and the per-directory distribution. | | Grep (read-only) | Content search with regex. A capped result reports the total count, plus the per-directory distribution in files_with_matches and count modes. | | WebFetch (read-only) | General-purpose HTTP GET — converts HTML to markdown, returns JSON/XML/text raw. Retries transient failures (network, timeouts, 408/429/5xx) up to 3 attempts. | | Shell | Run shell commands. | | Write | Create, overwrite, or append to files. | | Edit | Surgical text replacement. |

Shell, Write, and Edit are not concurrency-safe, so within one turn each runs alone — no two of them, and nothing else, is ever in flight at the same time. Two concurrent Edits on one file can therefore no longer lose an update.

Interactive tools are off by default and registered only when explicitly enabled via builtInTools (askUser: true, todoWrite: true, subAgent: true):

| Tool | Description | |---|---| | AskUser | Ask the user 1-4 questions in one call (each with 2-4 options and optional multi-select) and wait for the answers. | | TodoWrite | Track multi-step task progress; the agent must complete every task before its final reply. | | Skill | Invoke a skill by name; loads its instructions into context. Registered automatically whenever skills are provided. | | SubAgent | Run a nested sub-agent. Types: "explore" — read-only fan-out investigation; "plan" — read-only implementation planning; "general" — writable executor that may modify files and run shell commands, for delegating whole implementation chunks (run several in parallel for multi-task work). Tool grants follow each tool's agentLevel: read-only sub-agents get level-1 tools, "general" gets level 1+2; level-0 tools (AskUser/Skill/TodoWrite) are never delegated. Sub-agents delegate further one nesting step at a time, up to maxSubAgentDepth (default 3) levels below the session: the deepest level gets no SubAgent tool, and a read-only sub-agent can only delegate to read-only sub-agents, so it cannot escalate to a writable child. When the session is built read-only (builtInTools: { readOnly: true }), "general" is not offered and the tool falls back to explore/plan only. At most maxConcurrentSubAgents (default 4) sub-agents run at once per session — bounded also by maxParallelToolCalls, which caps the calls issued in one turn. Calls beyond the budget that come from the session's own turn wait for a free slot and run in the same turn, in call order; once one of that turn's sub-agents ends with a status other than ok, the calls still waiting are skipped with an error result instead of starting, and nested delegations are refused with an error instead of waiting (a parent run holds its slot while it awaits its children, so waiting there would deadlock the tree). Each sub-agent run gets maxSubAgentTurns tool-calling turns (default: the session's maxTurns), at every nesting level. A sub-agent returns only its final report as text — no intermediate steps, and no notes file is written for its run. Verify their changes yourself. |

builtInTools: false disables all built-in tools.

const session = await createSession({
  systemPrompt: "...",
  llm: { ... },
  builtInTools: { askUser: true, todoWrite: true },
});
// Disable all built-in tools:
// builtInTools: false
// Read-only session (no Shell / Write / Edit):
// builtInTools: { readOnly: true }

Composing the built-in tools

The seven stateless built-ins are exported as tool objects — fileReadTool, globTool, grepTool, webFetchTool, shellTool, fileWriteTool, fileEditTool — along with BUILTIN_TOOLS, the array they live in. That lets a host register any subset (builtInTools: false plus its own list) or wrap one of them:

import { BUILTIN_TOOLS, createSession } from "@vietor/agent-core";

const session = await createSession({
  systemPrompt: "...",
  llm: { ... },
  builtInTools: false,
  tools: [myTool, ...BUILTIN_TOOLS.filter((t) => t.name !== "Shell" && t.name !== "WebFetch")],
});

Two caveats: with builtInTools: false the system prompt drops the file-tool usage guidance (the tool schemas are still sent), and the interactive tools (AskUser/TodoWrite/Skill/SubAgent) have per-session state, so they stay builtInTools flags — and enabling any flag always brings the seven core tools along with it.

Same-name override: a custom tool whose name matches a built-in replaces it. Custom tools are registered after the built-ins and the later registration wins — this ordering is intentional and is the supported way to customize a built-in's behavior.

Custom tools example

With a hand-written JSON Schema, execute validates its own arguments:

import { toolError, type Tool } from "@vietor/agent-core";

const greetTool: Tool = {
  name: "greet",
  description: "Greet someone by name",
  parameters: {
    type: "object",
    properties: { name: { type: "string" } },
    required: ["name"],
  },
  async execute(args) {
    const name = args.name;
    if (typeof name !== "string" || !name) return toolError("name is required");
    return { content: `Hello, ${name}!` };
  },
};

const session = await createSession({
  systemPrompt: "...",
  llm: { ... },
  tools: [greetTool],
});

Skill System

Skill

interface Skill {
  name: string;
  description?: string;
  prompt: string;
}

Skills are loaded from directories containing a SKILL.md file. They are listed in the system prompt so the agent can invoke them via the built-in Skill tool, and can also be run directly by hosts via session.runSkill(name) (e.g. mapped to slash commands).

tryLoadSkills

tryLoadSkills(path: string): Skill[] | undefined

Load skills from a directory. Each subdirectory containing a SKILL.md file becomes one skill. Returns undefined if the directory doesn't exist or contains no valid skills.

SKILL.md supports front matter:

---
name: my-skill
description: Does something useful
---

Your skill prompt here.

If no name is set in front matter, the directory name is used.

const skills = tryLoadSkills("./my-skills") ?? [];

const session = await createSession({
  systemPrompt: "...",
  llm: { ... },
  skills,
});

MCP (Model Context Protocol)

MCPServerConfig

MCPServerConfigSchema is the single source of truth (hosts compose it into their own config schema); MCPServerConfig is its input shape and ResolvedMCPServerConfig its parsed shape:

const MCPServerConfigSchema = z.union([
  z.object({
    type: z.literal("stdio").default("stdio"),
    command: z.string(),
    args: z.array(z.string()).optional(),
    env: z.record(z.string(), z.string()).optional(),
    enabled: z.boolean().optional(),    // set false to skip this server
  }),
  z.object({
    type: z.literal("http"),
    url: z.url(),
    headers: z.record(z.string(), z.string()).optional(),
    enabled: z.boolean().optional(),
  }),
]);

type MCPServerConfig = z.input<typeof MCPServerConfigSchema>;

type ResolvedMCPServerConfig = z.infer<typeof MCPServerConfigSchema>;

type is optional on input for stdio servers — the schema defaults it to "stdio". mcpServers on createSession and connectMCP take MCPServerConfig; the manager parses each server at connect time, so MCPServerInfo.type is always set and the transport is selected from the parsed shape.

MCPServerInfo

Returned by session.mcpServers.

interface MCPServerInfo {
  name: string;
  type: "stdio" | "http";
  status: "pending" | "connected" | "failed" | "disabled";
  tools: string[];
  error?: string;  // connection error message when status is "failed"
}

MCP tools are exposed to the LLM with the prefixed name MCP__<server>__<tool>. Connection timeout is 30 seconds per server.

session.connectMCP

connectMCP(servers: Record<string, MCPServerConfig>): Promise<void>

Connect additional MCP servers after session creation. Called by createSession when mcpServers is set (without awaiting it) and usable by hosts to add servers at runtime; await it when the caller needs the servers up before proceeding.


API Reference — @vietor/agent-core/util

All utilities below are exported from the @vietor/agent-core/util subpath.

Async helpers

withRetry

withRetry<T>(fn: () => Promise<T>, opts: RetryOptions): Promise<T>

Run fn with retry. On a retryable error, waits backoff(attempt) (abortable via opts.signal) and retries, up to opts.retries retries; the last error is rethrown.

interface RetryOptions {
  retries: number;                    // additional attempts after the first failure
  retryable: (e: unknown) => boolean; // decide whether an error warrants a retry
  backoff: (attempt: number) => number; // delay in ms before retry attempt
  onRetry?: (attempt: number, max: number, error: unknown) => void;
  signal?: AbortSignal;
}

withTimeout

withTimeout<T>(p: Promise<T>, ms: number): Promise<T>

Reject the promise with Error("timeout after Nms") if it doesn't settle within ms.

withTimeoutFn

withTimeoutFn<T>(fn: (signal: AbortSignal) => Promise<T>, ms: number, signal: AbortSignal | undefined, timeoutMessage: string, otherError?: (e: unknown) => unknown): Promise<T>

The function variant of withTimeout: calls fn with a signal that aborts on timeout or external abort. On timeout throws Error(timeoutMessage); otherError optionally remaps other rejections.

withAbort

withAbort<T>(promise: Promise<T>, signal?: AbortSignal, onAbort?: () => T): Promise<T>

Race promise against signal. On abort, resolves onAbort() if provided, otherwise rejects with AbortedError. Pass no signal to return the promise untouched.

AbortedError

class AbortedError extends Error

Thrown by withAbort (and abortable retry sleeps) when an operation is aborted.

isAbortError

isAbortError(e: unknown): boolean

true for AbortedError, the DOM AbortError, or the LLM SDK's APIUserAbortError — use it to distinguish user aborts from real errors.

backoffDelay

backoffDelay(attempt: number): number

Exponential backoff in ms: 1000 * 2 ** attempt. Suitable as the backoff field of RetryOptions.

mapWithConcurrency

mapWithConcurrency<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>, signal?: AbortSignal): Promise<R[]>

Map over items keeping at most limit calls in flight (unlike Promise.all, never runs more than limit at once), starting the next item as soon as one settles instead of waiting for a whole batch to finish. Results are returned in input order, regardless of the order calls complete in.

  • Aborting signal stops new items from being claimed; calls already in flight are not cancelled and still settle. The result is the items that completed, in input order.
  • If a call rejects, no further items are started and the promise rejects with the error of the lowest-index item that failed. This matters because callers stop waiting the moment the promise settles: without it, the rest of the array would keep running in the background with nothing left to cancel it.

Text helpers

formatDuration

formatDuration(value: number): string

Format a duration in seconds for display, upgrading to larger units when needed (e.g. 3.2s, 1m 30s, 1h 1m 5s). Used for tool-result summaries and the CLI spinner.

import { formatDuration } from "@vietor/agent-core/util";

formatDuration(3.24);    // "3.24s"
formatDuration(90.4);    // "1m 30s"
formatDuration(3665);    // "1h 1m 5s"

formatCompactNumber

formatCompactNumber(value: number): string

Format a number compactly (e.g. 1.2K). Used for byte/line counts in summaries.

import { formatCompactNumber } from "@vietor/agent-core/util";

formatCompactNumber(1234);   // "1.23K"

getTextBytes

getTextBytes(content: string): number

Return the UTF-8 byte length of a string (via Buffer.byteLength).

import { getTextBytes } from "@vietor/agent-core/util";

const bytes = getTextBytes("Hello");   // 5

summarizeText

summarizeText(content: string, length: number, showChars?: boolean): string

Collapse whitespace and truncate text to length characters with a trailing …. With showChars, append the total character count when truncated — useful for text that changes size over time.

import { summarizeText } from "@vietor/agent-core/util";

summarizeText("a\nvery   long line", 8);              // "a very l…"
summarizeText("a very long line here", 8, true);      // "a very l… (21)"

toErrorMessage

toErrorMessage(e: unknown): string

Stringify an unknown error for display (e instanceof Error ? e.message : String(e)). Used across core for error events.

import { toErrorMessage } from "@vietor/agent-core/util";

toErrorMessage(new Error("boom"));   // "boom"
toErrorMessage("oops");              // "oops"

Subprocess

runProcess

runProcess(cmd: string, args: string[], opts?: { cwd?: string; timeout?: number }, signal?: AbortSignal): Promise<ProcessResult>

Run a subprocess, capturing stdout and stderr (used by the built-in Shell tool). The promise never rejects — spawn failures, timeouts, and output over the 10MB cap are reported via ProcessResult.error. Pass a timeout (ms) or an AbortSignal to kill the process tree. Live processes are killed on process exit.

import { runProcess } from "@vietor/agent-core/util";

const result = await runProcess("ls", ["-la"], { cwd: "./src" });
if (result.error) console.error(result.error.message);
else console.log(result.stdout, result.status);

ProcessResult

Returned by runProcess.

interface ProcessResult {
  stdout: string;
  stderr: string;
  status: number | null;  // exit code; null when killed (signal, abort, timeout, buffer overflow)
  error?: Error;          // spawn failure, timeout, or output exceeded the 10MB cap
  truncated?: boolean;    // true when output was cut off at the 10MB cap
}

findCommands

findCommands(commands: string[]): string[]

Return the subset of commands that exist on the PATH (uses where on Windows, command -v elsewhere). Useful for picking an available interpreter, e.g. node vs bun.

import { findCommands } from "@vietor/agent-core/util";

findCommands(["python", "python3"]);   // e.g. ["python"]

File

tryReadFileText

tryReadFileText(path: string): string | undefined

Read a text file, returning undefined on any failure (missing file, empty content, read error).

const content = tryReadFileText("./config.json");
if (content) {
  const config = JSON.parse(content);
}

HTML

htmlToMarkdown

htmlToMarkdown(html: string): string

Convert HTML to Markdown using Turndown. Strips script, style, title, meta, head, noscript, template, link, and base elements.

import { htmlToMarkdown } from "@vietor/agent-core/util";

const md = htmlToMarkdown("<h1>Hello</h1><p>World</p>");
// "# Hello\n\nWorld"

Network

netFetch

netFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>

A drop-in replacement for fetch that automatically routes through an HTTP(S) proxy when configured. Respects the standard environment variables:

  • HTTPS_PROXY / https_proxy — proxy URL for HTTPS requests (preferred)
  • HTTP_PROXY / http_proxy — proxy URL for HTTP requests (fallback)
  • NO_PROXY / no_proxy — comma-separated hostnames/domains to bypass the proxy
import { netFetch } from "@vietor/agent-core/util";

// Same signature as fetch — automatically uses proxy if env vars are set
const res = await netFetch("https://api.example.com/data");
const data = await res.json();

Constants

Tool-call timeouts, empty results, and text caps share constants here:

| Constant | Value | Meaning | |---|---|---| | CALL_TIMEOUT_MS | 300_000 | Default tool-call timeout (Shell, MCP tools) | | NO_OUTPUT | "(no output)" | Content placeholder for empty tool results | | MAX_SUMMARY_LENGTH | 75 | Default length cap for summarizeText |