Library guide
What you'll learn: how to install the package and embed the
Agentclass in TypeScript — basic runs, event subscription, multi-turn history, config, tool filtering, error handling, and session access.
Install
npm install @moikapy/lichFrom source instead (dist/ is not committed, so build first — see development install):
git clone https://github.com/Moikapy/lich.git && cd lich
bun install && bun run build
# then, from your project: npm install <path-to>/lichThe 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:
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.
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:
| Member | Type | Purpose |
|---|---|---|
run(options) | (AgentRunOptions) => Promise<AgentRunResult> | Run the loop to a final answer, budget exhaustion, or abort. |
events | AgentEmitter | Subscribe with events.on(handler); the returned function unsubscribes. |
config | AgentConfig | Frozen, fully-resolved config (defaults filled in). |
AgentRunOptions:
| Field | Type | Meaning |
|---|---|---|
input | string | Required user message for this run. |
history | Message[] | Prior conversation to continue (multi-turn). |
signal | AbortSignal | Cooperative cancellation; the loop returns outcome.stopped_reason: "aborted". |
label | string | Origin tag for the session filename (e.g. "tui", "gw:webhook:default"). Unused when session is set. |
session | SessionHandle | Optional shared transcript handle. When set, Agent.run reuses it instead of opening a new file (TUI: one handle per launch). |
AgentRunResult:
| Field | Type | Meaning |
|---|---|---|
outcome.final | AssistantMessage | undefined | The final assistant reply (undefined on abort/budget without content). |
outcome.turns_used | number | Turns consumed this run. |
outcome.stopped_reason | "final" | "budget" | "aborted" | Why the loop ended. |
messages | Message[] | Full transcript: your history plus the new exchange. |
usage_total | Usage | Summed {prompt_tokens, completion_tokens, total_tokens}. |
session_path | string | undefined | Session JSONL path, or undefined if persistence failed (logged warning, never throws). |
Events
Handlers receive a discriminated AgentEvent union; throwing handlers are logged, never fatal:
| Event | Payload |
|---|---|
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:
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:
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:
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.
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:
kind | Meaning | Failover behavior |
|---|---|---|
auth | 401/403 or bad credentials. | Immediate failover to the next provider. |
rate_limit | 429 or 5xx (with retry_after_ms when the server sends it). | 3 attempts with backoff, then failover. |
network | Fetch failed, timeout, or abort while connecting. | 3 attempts with backoff, then failover. |
overflow | Request exceeded the model's context window. | Immediate failover. |
bad_request | 400/422 or an unparseable success payload. | Immediate failover. |
unknown | Non-provider errors (e.g. tool crashes surfaced as strings). | Treated as fatal for the provider. |
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).