@open-agent-loops/core
v1.1.0
Published
A minimal, provider-agnostic agentic loop. Plug-and-play seams behind interfaces.
Maintainers
Readme
A minimal, provider-agnostic agentic loop. The whole point of this package is to make the components of the agent plug-and-play, independently testable, and reliable — every fundamental thing sits behind an interface, so it can be swapped without touching the loop.
Quickstart
Prerequisites: Node 20.6+ (for --env-file) and an
OpenAI-compatible endpoint (Featherless, vLLM, Together, Groq, …).
Install the package, its peer (openai), and zod. tsx runs the .ts files
below without a build step:
npm install @open-agent-loops/core openai zod
npm install -D tsx[!NOTE]
@open-agent-loops/coreisn't published to npm yet. Until it is, clone this repo and run the examples with Bun (bun run examples/<name>/<name>.ts, which auto-loads.env) — the source below is identical apart from the import paths.
Create a .env next to your script:
LLM_API_KEY=sk-... # API key for the endpoint
LLM_MODEL=zai-org/GLM-5.2 # model id to call
# LLM_BASE_URL defaults to https://api.featherless.ai/v1 — point it at any OpenAI-compatible endpointSingle-turn loop — one prompt, one answer
A local weather tool, a typed render over every AgentEvent, and the prompt
read from the terminal. Save as single-turn-loop.ts:
import { AgentEventType, defineTool, runAgent, SessionMemoryStore } from "@open-agent-loops/core";
import type { AgentEvent } from "@open-agent-loops/core";
import { OpenAICompatibleModel } from "@open-agent-loops/core/providers/openai";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { z } from "zod";
const weather = defineTool({
name: "weather",
description: "Get the current weather for a city.",
parameters: z.object({ city: z.string().describe("City to look up.") }),
execute: async ({ city }) => {
// Replace with a real API call to fetch the weather.
return { content: `Sunny in ${city}` };
},
});
// Batteries included: the OpenAI-compatible client, pointed at any endpoint.
const model = new OpenAICompatibleModel({
apiKey: process.env.LLM_API_KEY,
baseURL: process.env.LLM_BASE_URL ?? "https://api.featherless.ai/v1",
model: process.env.LLM_MODEL ?? "zai-org/GLM-5.2",
thinking: "on", // stream the reasoning channel so `render` actually shows it
});
// `onEvent` is your renderer. The loop is headless and emits a typed AgentEvent
// stream — `render` handles every event that flows through the loop.
function render(e: AgentEvent) {
switch (e.type) {
case AgentEventType.AgentStart:
console.log(`▶ start · session ${e.sessionId}`);
break;
case AgentEventType.TurnStart:
console.log(`\n— turn ${e.step} —`);
break;
case AgentEventType.ReasoningDelta:
// The reasoning channel — dim it so it reads as distinct from the answer.
process.stdout.write(`\x1b[2m${e.text}\x1b[22m`);
break;
case AgentEventType.TextDelta:
process.stdout.write(e.text);
break;
case AgentEventType.Message:
console.log(`\n· ${e.message.role} message complete`);
break;
case AgentEventType.ToolStart:
console.log(`→ ${e.toolName}(${JSON.stringify(e.args)})`);
break;
case AgentEventType.ToolEnd:
console.log(`← ${e.toolName} [${e.isError ? "error" : "ok"}]: ${e.result}`);
break;
case AgentEventType.AgentEnd:
console.log(`\n■ done · ${e.steps} steps`);
break;
}
}
// Read the question from the terminal — type "What's the weather in Paris?".
const rl = createInterface({ input, output });
const prompt = await rl.question("you › ");
rl.close();
const result = await runAgent({
model,
memory: new SessionMemoryStore(), // batteries-included in-memory conversation store
sessionId: "single-turn-demo",
prompt,
tools: [weather],
onEvent: render,
});
console.log(`\n${result.messages.at(-1)?.content}`);Run it (--env-file loads your .env):
npx tsx --env-file=.env single-turn-loop.tsMulti-turn chat — remembers every prior turn
The same runAgent call, wrapped in a read-input loop that reuses one memory
sessionId, so every turn sees the ones before it. Save asmulti-turn-chat.ts:
import { AgentEventType, defineTool, runAgent, SessionMemoryStore } from "@open-agent-loops/core";
import type { AgentEvent } from "@open-agent-loops/core";
import { OpenAICompatibleModel } from "@open-agent-loops/core/providers/openai";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { z } from "zod";
const apiKey = process.env.LLM_API_KEY;
const modelId = process.env.LLM_MODEL;
if (!apiKey || !modelId) {
console.error("Set LLM_API_KEY and LLM_MODEL (see .env above).");
process.exit(1);
}
const weather = defineTool({
name: "weather",
description: "Get the current weather for a city.",
parameters: z.object({ city: z.string().describe("City to look up.") }),
execute: async ({ city }) => {
// Replace with a real API call to fetch the weather.
return { content: `Sunny in ${city}` };
},
});
const model = new OpenAICompatibleModel({
apiKey,
model: modelId,
baseURL: process.env.LLM_BASE_URL ?? "https://api.featherless.ai/v1",
thinking: "on", // stream the reasoning channel so `render` actually shows it
});
// The same renderer as the single-turn loop — it handles every event the loop emits.
function render(e: AgentEvent) {
switch (e.type) {
case AgentEventType.AgentStart:
console.log(`▶ start · session ${e.sessionId}`);
break;
case AgentEventType.TurnStart:
console.log(`\n— turn ${e.step} —`);
break;
case AgentEventType.ReasoningDelta:
// The reasoning channel — dim it so it reads as distinct from the answer.
process.stdout.write(`\x1b[2m${e.text}\x1b[22m`);
break;
case AgentEventType.TextDelta:
process.stdout.write(e.text);
break;
case AgentEventType.Message:
console.log(`\n· ${e.message.role} message complete`);
break;
case AgentEventType.ToolStart:
console.log(`→ ${e.toolName}(${JSON.stringify(e.args)})`);
break;
case AgentEventType.ToolEnd:
console.log(`← ${e.toolName} [${e.isError ? "error" : "ok"}]: ${e.result}`);
break;
case AgentEventType.AgentEnd:
console.log(`\n■ done · ${e.steps} steps`);
break;
}
}
const memory = new SessionMemoryStore(); // one store, reused every turn
const sessionId = "chat"; // same id every turn → one conversation
const rl = createInterface({ input, output });
while (true) {
const prompt = (await rl.question("\nyou › ")).trim();
if (prompt === "" || prompt === "exit") break;
process.stdout.write("bot › ");
await runAgent({ model, memory, sessionId, prompt, tools: [weather], onEvent: render });
}
rl.close();Run it, then ask a follow-up ("and in London?") to see it carry context across
turns. Type exit or an empty line to quit:
npx tsx --env-file=.env multi-turn-chat.tsMore examples (steering, console formatting) live in examples/.
Documentation
📖 Read the docs online: https://openagentloops.featherless.ai/docs/
The full documentation is a fumadocs site under
docs-fuma/, published to GitHub Pages on every push to main
(see .github/workflows/deploy-docs.yml).
To run it locally:
cd docs-fuma
bun install # first time only
bun run devThen open http://localhost:3000/docs. (From the repo root, bun run docs:site
does the same.)
