Skip to content

Library guide ​

What you'll learn: how to install the package and embed the Agent class in TypeScript — basic runs, event subscription, multi-turn history, config, tool filtering, error handling, and session access.

Install ​

sh
npm install @moikapy/lich

From source instead (dist/ is not committed, so build first — see development install):

sh
git clone https://github.com/Moikapy/lich.git && cd lich
bun install && bun run build
# then, from your project: npm install <path-to>/lich

The package ships ESM (dist/index.js, types at dist/index.d.ts, binary at dist/cli.js); main/types/bin are wired in package.json.

Minimal example ​

create_agent(raw_config) validates the config (zod, defaults applied, frozen result) and returns an Agent with a .run() loop:

ts
import { create_agent } from "@moikapy/lich";

const agent = create_agent({
  providers: [{ kind: "ollama", name: "local", model: "qwen3:8b" }],
});

const result = await agent.run({ input: "Use list_dir to list the files, then summarize." });
console.log(result.outcome.final?.content);
console.log(`tokens: ${result.usage_total.total_tokens}`);

run_agent(config, input) loads config.plugins, then runs once. create_agent does not load plugins.

ts
import { run_agent } from "@moikapy/lich";

const result = await run_agent(
  { providers: [{ kind: "ollama", name: "local", model: "qwen3:8b" }] },
  "Reply with ok",
);

Agent class ​

new Agent(config) (or create_agent(raw)) builds the provider router, registers the builtin tools and the gatekeeper's git_commit (all filtered by tools_enabled), and exposes:

MemberTypePurpose
run(options)(AgentRunOptions) => Promise<AgentRunResult>Run the loop to a final answer, budget exhaustion, or abort.
eventsAgentEmitterSubscribe with events.on(handler); the returned function unsubscribes.
configAgentConfigFrozen, fully-resolved config (defaults filled in).

AgentRunOptions:

FieldTypeMeaning
inputstringRequired user message for this run.
historyMessage[]Prior conversation to continue (multi-turn).
signalAbortSignalCooperative cancellation; the loop returns outcome.stopped_reason: "aborted".
labelstringOrigin tag for the session filename (e.g. "tui", "gw:webhook:default"). Unused when session is set.
sessionSessionHandleOptional shared transcript handle. When set, Agent.run reuses it instead of opening a new file (TUI: one handle per launch).

AgentRunResult:

FieldTypeMeaning
outcome.finalAssistantMessage | undefinedThe final assistant reply (undefined on abort/budget without content).
outcome.turns_usednumberTurns consumed this run.
outcome.stopped_reason"final" | "budget" | "aborted"Why the loop ended.
messagesMessage[]Full transcript: your history plus the new exchange.
usage_totalUsageSummed {prompt_tokens, completion_tokens, total_tokens}.
session_pathstring | undefinedSession JSONL path, or undefined if persistence failed (logged warning, never throws).

Events ​

Handlers receive a discriminated AgentEvent union; throwing handlers are logged, never fatal:

EventPayload
turn_start / turn_end{ turn }
llm_start{ turn }
llm_end{ turn, result: ChatResult } — carries usage per call.
tool_call_start{ turn, call: ToolCall }
tool_call_end{ turn, call, result: ToolResult }
compress_start{ estimated_tokens }
compress_end{ summary_chars }
final{ message, result } — the answer that ends the run.
budget_exhausted{ turns_used }
error{ error } — provider/loop errors; the run may still recover via failover.

Print every tool call as it happens:

ts
const agent = create_agent(config);
const unsubscribe = agent.events.on((event) => {
  if (event.type === "tool_call_end") {
    const status = event.result.ok ? "ok" : `error: ${event.result.error}`;
    console.log(`[tool] ${event.call.name}(${JSON.stringify(event.call.args)}) -> ${status}`);
  }
});
try {
  await agent.run({ input: "List the repo and find TODO comments" });
} finally {
  unsubscribe();
}

Multi-turn conversations ​

Pass prior result.messages back in as history:

ts
let history: Message[] = [];
for (const question of ["What files are in the repo?", "Which one is largest?"]) {
  const result = await agent.run({ input: question, history });
  console.log(result.outcome.final?.content);
  history = result.messages;
}

Config reference ​

Same schema as the CLI config file — see the config file reference for the full field table. As a library caller you normally construct it directly:

ts
const config = {
  providers: [
    { kind: "openai_compat", name: "openrouter", model: "meta-llama/llama-3.1-8b-instruct",
      base_url: "https://openrouter.ai/api/v1", api_key_env: "OPENROUTER_API_KEY" },
    { kind: "ollama", name: "local", model: "qwen3:8b" },  // failover target
  ],
  max_turns: 25,
  tools_enabled: ["read_file", "list_dir", "terminal", "web_search", "fetch_url"],
  session_dir: "./.lich/sessions",
};

Listed providers form a failover chain tried in order: rate_limit/network errors retry with backoff (3 attempts) on the current provider before failing over; auth, overflow, and bad_request fail over immediately. The last error is rethrown when all providers fail. An optional models block splits the chain by role: models.chat sets the main loop's order and models.compress the context-compression chain (see the config reference).

LICH_ALLOW_SELF_COMMIT and LICH_TEST_COMMAND are process-env knobs, not config fields. See the CLI environment.

Custom tool filtering ​

tools_enabled accepts "all" (default) or an array of tool names to register; everything else stays unregistered and invisible to the model. MCP tools, when a named server is enabled, use the same allowlist and stay off when the list is [] (that empty list does not connect). The same list applies to plugin tools, including the gatekeeper's git_commit (fail-closed unless LICH_ALLOW_SELF_COMMIT=1), so list each plugin tool you want exposed. [] exposes no tools and does not throw.

mcp_servers and catalog_client_entry ship in this package (since 0.7.0). See the Redot guide.

ts
const agent = create_agent({
  providers: [{ kind: "ollama", name: "local", model: "qwen3:8b" }],
  tools_enabled: ["read_file", "grep_files", "list_dir"],
});

Error handling ​

Provider failures throw ProviderError, an Error subclass with kind, provider_name, optional status and retry_after_ms:

kindMeaningFailover behavior
auth401/403 or bad credentials.Immediate failover to the next provider.
rate_limit429 or 5xx (with retry_after_ms when the server sends it).3 attempts with backoff, then failover.
networkFetch failed, timeout, or abort while connecting.3 attempts with backoff, then failover.
overflowRequest exceeded the model's context window.Immediate failover.
bad_request400/422 or an unparseable success payload.Immediate failover.
unknownNon-provider errors (e.g. tool crashes surfaced as strings).Treated as fatal for the provider.
ts
import { ProviderError } from "@moikapy/lich";

try {
  await agent.run({ input: "hello" });
} catch (error) {
  if (error instanceof ProviderError) {
    console.error(`${error.provider_name} failed: ${error.kind} — ${error.message}`);
  }
  throw error;
}

When every configured provider fails, the last ProviderError is thrown. Tool failures are not exceptions: they return { ok: false, output, error } into the loop as tool messages for the model to react to. Cancellation via signal ends the run with stopped_reason: "aborted" rather than throwing.

Games ​

A Godot client does not embed the library. A game backend that does is still one Agent per persona, not a second loop. The pattern — factory, history cap, per-conversation queue, POST /message → {reply, usage} — is examples/persona_orchestrator. The service itself is game-repo work. Session files as a combat log: games guide.

Session access ​

Each run() appends to a JSONL transcript under config.session_dir (default <work_dir>/.lich/sessions) while the loop runs: run_start, seeded system/user (plus history when the handle is new), then assistant/tool messages on llm_end / tool_call_end, optional budget_exhausted / compress_end meta, and run_end (stopped_reason, usage matching usage_total) when the run completes. Pass session to reuse one handle across runs (the TUI does this so one launch = one file). Omit it for per-run files (one-shot, chat, gateway). result.session_path is the handle path when recording started. Records carry {ts, kind: "message"|"meta", message?, meta?}. Provider throws leave whatever was already appended; they do not write run_end. read_session_messages drops every trailing user message so resume does not start with consecutive user turns. Read transcripts with jq (see the games guide) or read_session_messages(path) from a source checkout — it is not a package export. Persistence is best-effort: a write failure logs a warning and never fails the run.

CLI resume: lich --resume <id|latest> (TUI only) resolves a transcript via src/session/resolve.ts, loads messages with read_session_messages, and seeds the TUI history. New turns append into the launch's shared handle (history is written once on first seed).

Released under the MIT License.