Open Agent Loops

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:

SeamInterfaceShips with
LLM boundaryModelClientOpenAICompatibleModel (+ MockModelClient for tests)
MemoryMemorySessionMemoryStore
CapabilitiesTooldefineTool(...)
StoppingStopConditionmaxSteps, whenToolCalled

The Core Agentic Loop

swap any seam:MemoryModelClientToolStopConditionHooks · 5 extension points
Dashed grey = external input (the caller's prompt). 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 openai

Then 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:

examples/single-turn-loop/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, // 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:

examples/multi-turn-chat/multi-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.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 test

Every 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.

On this page