Getting Started
Run your first agent loop, add a real model client, and gate tool calls.
Open Agent Loops is a minimal, provider-agnostic agentic loop. Every fundamental piece sits behind an interface so it can be swapped without touching the loop — the point of the package is to make the components of the agent plug-and-play, independently testable, and reliable.
The Seams
The loop (runAgent) only ever depends on these interfaces:
| Seam | Interface | Ships with |
|---|---|---|
| LLM boundary | ModelClient | OpenAICompatibleModel (+ MockModelClient for tests) |
| Memory | Memory | SessionMemoryStore |
| Capabilities | Tool | defineTool(...) |
| Stopping | StopCondition | maxSteps, whenToolCalled |
The Core Agentic Loop
drainSteering injects queued messages mid-run; drainFollowUp continues past a final answer — both bounded by maxSteps. Drag the nodes to rearrange.Each turn streams an assistant message; if it makes tool calls, the loop runs
them, appends the results, and repeats — until the model returns a final answer,
a tool sets terminate, or a stopWhen / maxSteps condition fires.
Single-Turn Agent Loop
This talks to a real API out of the box with two batteries-included pieces:
the OpenAICompatibleModel client (here pointed at Featherless, though any
OpenAI-compatible endpoint works) and the in-memory SessionMemoryStore.
Install the provider peer:
npm install openaiThen set LLM_API_KEY in your environment and run it — it prompts you for a
question on the terminal, where you can type something like "What's the weather
in Paris?". This snippet is embedded from the runnable example, so it can't
drift from the code you'd actually run:
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, // set this in your environment
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 instead of hardcoding it — type something
// like "What's the weather in Paris?" when prompted.
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, // whatever you typed above
tools: [weather],
onEvent: render, // the named renderer defined above
});
console.log(`\n${result.messages.at(-1)?.content}`);A building block for sub-agents
Because a single-turn run is just prompt → result, it's the natural unit for
a sub-agent: give a focused runAgent call its own tools, system prompt,
and a fresh SessionMemoryStore, then expose it as a tool the parent agent can
call. The parent gets back one result, and the sub-agent's intermediate turns
stay out of the parent's context.
Multi-Turn Chat Loop
The single-turn loop above sends one prompt. To hold a conversation, call
runAgent again with the same memory and sessionId — each run loads the
prior history, appends the new turn, and writes the model's reply back, so the
next run already remembers everything before it. A chat loop is just that call
inside a read-input loop, reusing the model, weather tool, and render from
the single-turn example above. Like that snippet, it's embedded from the
runnable example:
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.example).");
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 named 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();Why it remembers
Nothing about multi-turn is special-cased in the loop: runAgent reads history
from memory at the start of every run and persists the prompt, assistant
reply, and tool results back. Reuse the store and sessionId to continue a
conversation; change the sessionId — or call memory.clear(sessionId) — to
start a fresh one. Swap SessionMemoryStore for a durable backend to survive
restarts.
Messages & the Wire Format
Under that loop is one data model: an ordered array of messages, each tagged
with a role — system, user, assistant, tool. That array is the wire,
mapping almost one-to-one onto the OpenAI chat-completions format every provider
speaks. It has its own short guide —
Messages & the Wire Format — covering how
the four roles interleave across turns, how a tool call pairs one-to-one with its
result (even under parallel and interleaved tool calling), and what the SDK adds
on top of the bare wire.
Tools
A tool is a capability you hand the model: a name, a one-line description, a
Zod schema for its arguments, and an execute handler. The loop validates every
call against the schema before execute runs, so your handler can trust its
input. It has its own short guide — Tools — growing one program
across five steps: define a tool, let it fail safely, organize many in a
ToolRegistry, reach for a built-in over your own backend, and gate a risky call
behind approval.
Code Execution
Where the shell tool runs a command, code_execution runs a program — a
snippet the model writes and your machine runs for real, handing back the captured
output. Reach for it when the model should compute an answer instead of guessing
one. Like every dangerous built-in, the SDK owns the model-facing contract and
you own the backend that runs the code. It has its own short guide —
Code Execution — covering why it's separate from
shell, the Deno backend the repo ships, the prompt that steers it, and how
inputs and outputs behave.
Skills
A skill is a named bundle the model pulls in on demand: a one-line description that rides in the system prompt every turn, full instructions disclosed only when the model invokes the skill, and the tools the skill contributes. That progressive disclosure keeps the expensive instructions out of context until they're needed. It has its own short guide — Skills — adding a skill to the multi-turn loop, then guarding it with a credential and an approval.
Planning Tools
A long, multi-step task needs the model to hold a plan and a to-do list it
can revise across turns — and the trick is to make it write to a tool, since a
tool result stays in the transcript while a turn's private reasoning doesn't. Two
batteries-included tool sets, scratchpadTools() and todoListTools(), do
exactly that, each backed by a swappable in-memory store. They have their own
short guide — Planning Tools — covering both tool
sets and the stores behind them.
Bring Your Own Front End
The loop is headless: the onEvent handler above is a renderer — onEvent
emits a typed AgentEvent stream and you present it however you like (stdout,
the DOM, a TUI, a log sink). It has its own short guide —
Bring Your Own Front End — covering a custom
renderer (Part 1) and a quick look at the ready-made Tracer (Part 2).
Bring Your Own Model Client
The single-turn loop uses the built-in OpenAICompatibleModel. For a different
provider, transport, or wire format, ModelClient is just one method —
implement stream() yourself. It has its own short guide —
Bring Your Own Model Client — covering
the minimal contract (Part 1) and a real provider over raw fetch (Part 2).
Tracing
A Tracer records the trajectory of a run for debugging — a passive observer
that rides the same onEvent sink and provider wire taps, so capture can never
change a run. Every captured item becomes one timestamped entry you can fold into
a trajectory, render as a timeline, or turn back into the exact HTTP call that
produced it. It has its own short guide — Tracing — attaching
a Tracer, capturing the request wire, and reconstructing a replayable curl.
Permissions & Credentials
Some tool calls are risky enough that a human should approve them first. The
gateToolCalls hook is a single admission point — run once per turn, before any
tool executes — and gating is batteries-included: pair it with the shipped
permissionGate and InMemoryPermissionStore for an allow / deny / ask
policy. It has its own short guide —
Permissions & Credentials — covering the permission policy
and composing it with withCredentials for a tool that needs a secret and
approval.
Tests
bun test agent-loop-core # or: bun run testEvery suite covers a base case plus edge cases. The loop, memory, tools, and
stop conditions are all verified with the streaming MockModelClient — zero
network, fully deterministic.
Open Agent Loops
A minimal, provider-agnostic agentic loop built on swappable interfaces.
Messages & the Wire Format
How the loop models a conversation — the four message roles, how user / assistant / tool turns interleave, and how tool calls pair one-to-one with tool results, even under parallel and interleaved tool calling.