@incubator/agent-loop (1.0.0)
Installation
@incubator:registry=npm install @incubator/agent-loop@1.0.0"@incubator/agent-loop": "1.0.0"About this package
agentic
An opinionated agentic loop library for TypeScript. The same Tool definition can be invoked directly by the LLM or run inside a sandboxed QuickJS environment — your choice, per tool. With type checking, parallel execution, and human-in-the-loop input.
Code execution (optional)
The library ships a sandboxTool factory that exposes a sandbox to the LLM as run_script. When you compose it (paired with describeTool), the agent can write TypeScript that runs in QuickJS with your tools available as global async functions — type-checked, parallel-capable, and isolated.
┌─────────┐ ┌─────────────────┐
│ LLM │──────▶│ describe_tools │
│ │ └────────┬────────┘
│ │ │ TypeScript type signatures
│ │◀───────────────┘
│ │
│ │ ┌─────────────────┐
│ │──────▶│ run_script │
└─────────┘ └────────┬────────┘
│ agent-authored TypeScript
▼
┌─────────────────┐
│ compiler │ ← type-check against tool signatures
└────────┬────────┘
▼
┌─────────────────┐
│ QuickJS (Wasm) │ ← isolated sandbox
│ │
│ Promise.all([ │ ← parallel tool calls
│ get_weather… │
│ get_stock… │
│ ]) │
│ console.log(…) │ ← captured output
│ return result │ ← returned to LLM
└─────────────────┘
Because the agent writes real code, it gets composition, variables, branching, and parallel execution (Promise.all) for free — no framework abstractions needed.
This also means the LLM doesn't have to see large data. A tool can return a massive dataset into the sandbox — the agent processes it in code, aggregates or filters it, and only console.logs the parts that matter back to the context window. Variables persist across sandbox invocations within a session, so follow-up questions can reuse data that's already loaded without fetching or re-processing it.
You can also expose tools directly to the LLM (no sandbox involved) — just include them in tools: [...]. The same Tool definition works either way; sandbox-vs-direct is a composition choice, not a different abstraction.
Data model
The core data structure is the Prompt — not a list of chat messages, but a domain-level representation of one unit of agent work:
Prompt
├─ input — one user input (text, image, or custom data)
├─ status — pending | running | completed | failed | token_budget_exceeded | awaiting_ask
└─ outputs[] — many typed outputs
├─ text — prose the agent wrote
├─ tool_call — every LLM tool call: name, args, result, status, optional pendingAsk/askOutcome
├─ structured — typed structured data (via zod schema)
└─ custom — arbitrary rich output from tool calls (widgets, charts)
Every LLM tool invocation — whether direct or run_script — uses the same tool_call envelope. The sandbox-specific shape (logs, journal, code, inner tool calls) lives inside tool_call.result when name === "run_script".
A conversation is a sequence of Prompt objects. Each prompt captures what the agent actually did — not an interleaved log of API messages. The input is semantic (what was asked), the outputs are semantic (what was produced), and the raw LLM message format is a conversion detail handled internally. This makes it straightforward to render in a UI, persist to a database, or inspect programmatically without parsing chat transcripts.
Human-in-the-loop
When a tool needs human involvement, it throws AskRequired from handle — a structured request that pauses execution and surfaces a typed schema and use-case-specific data to the host application. This can represent anything: an approval gate, a form to fill, a file to select, or a custom UI interaction. The host decides how to render and collect the response, then resumes execution. Because the ask carries its own schema and data, the interaction is driven by the tool's domain — not by the LLM or a generic chat interface.
The same mechanism works for direct LLM tools and tools called from inside the sandbox: throw AskRequired, loop pauses, host responds, handle is re-invoked with ask: { type: "resolved", answer } (or { type: "rejected" }).
Tool raises ask
│
▼
┌──────────────────────────────────┐
│ type: "file_select" │
│ schema: { path: string } │ ← typed contract
│ data: { cwd: "/src", ext: ".ts" } ← render hints for the host
└──────────────────────────────────┘
│
│ execution pauses
▼
Host app renders UI (dialog, form, terminal prompt, ...)
│
│ user responds
▼
loop.resolveAsk({ path: "/src/index.ts" })
│
│ execution replays and resumes
▼
Tool receives answer, continues
Quick start
Single-shot — direct tool
Use Loop directly when you need a single request-response. The simplest case: pass tools the LLM calls directly.
import OpenAI from "openai";
import { Loop, defineTool, prompt, buildSystemPrompt } from "agentic";
import { z } from "zod";
const weather = defineTool({
id: "get_weather",
description: "Get current weather for a city",
inputSchema: z.object({ city: z.string() }),
outputSchema: z.object({ city: z.string(), temp: z.number(), condition: z.string() }),
async handle({ input }) {
return { city: input.city, temp: 22, condition: "sunny" };
},
});
const tools = [weather];
const loop = new Loop({
client: new OpenAI(),
tools,
history: [prompt({ input: "What's the weather in Tokyo?", model: "gpt-4", systemPrompt: buildSystemPrompt(tools) })],
});
loop.on("text:delta", (_id, delta) => process.stdout.write(delta));
const result = await loop.run();
Single-shot — sandbox + describe
For tasks where the agent benefits from writing code (data wrangling, multi-step computation), wire up the sandbox factories. Inner tools are exposed as global async functions inside the sandbox; the LLM calls describe_tools to discover them and run_script to use them.
import { Loop, prompt, sandboxTool, describeTool, sandboxAgentPrompt, buildSystemPrompt } from "agentic";
const sandboxTools = [readFile, writeFile, search]; // your own Tools
const llmTools = [
sandboxTool({ tools: sandboxTools }),
describeTool({ tools: sandboxTools }),
];
const loop = new Loop({
client: new OpenAI(),
tools: llmTools,
history: [prompt({
input: "Summarize the deploy logs",
model: "gpt-4",
systemPrompt: `${buildSystemPrompt(llmTools)}\n\n${sandboxAgentPrompt}`,
})],
});
You can mix: pass direct LLM tools and the sandbox factories side by side.
Multi-turn conversation
Use Conversation when you need ongoing dialogue — chatbots, interactive agents, multi-step workflows. It manages history and event wiring for you:
import OpenAI from "openai";
import { Conversation, defineTool, buildSystemPrompt } from "agentic";
import { z } from "zod";
import * as readline from "readline/promises";
const client = new OpenAI();
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const weather = defineTool({
id: "get_weather",
description: "Get current weather for a city",
inputSchema: z.object({ city: z.string() }),
outputSchema: z.object({ city: z.string(), temp: z.number(), condition: z.string() }),
async handle({ input }) {
return { city: input.city, temp: 22, condition: "sunny" };
},
});
const tools = [weather];
const conversation = new Conversation({
client,
tools,
model: "gpt-4",
systemPrompt: buildSystemPrompt(tools),
});
// Events wired once — fire for every turn
conversation.on("text:delta", (_loop, _id, delta) => process.stdout.write(delta));
while (true) {
const input = await rl.question("\n> ");
if (!input) break;
await conversation.turn({ input }).run();
}
Features
- Code sandbox — tools run inside QuickJS (WebAssembly), fully isolated
- Type checking — LLM code is compiled with TypeScript before execution; type errors are returned as feedback
- Parallel execution —
Promise.allworks naturally inside the sandbox - Human-in-the-loop — tools can define an
askgate that returns{ type, schema, data }to request user input (approval, forms, widget selection); execution pauses and replays (Temporal-style) after the input is provided vialoop.resolveAsk(answer)or rejected vialoop.rejectAsk() - Context — pass authentication, environment, or shared state through to tool handlers via
context; usecreateToolBuilder<TContext>()for fully typed context in handlers - Persistent state — a
globalobject carries data between sandbox invocations within a turn and across conversation turns; onlyglobal.*properties persist, keeping state explicit and avoiding bloat - Deterministic replay —
Math.randomandDateare seeded/frozen so replayed scripts produce identical results - Streaming events — token-by-token text deltas, tool call lifecycle, state snapshots for real-time UI
- Stop & resume —
loop.stop()/AbortSignalsupport;loop.promptgives a live snapshot for persistence; resume from serialized state including mid-ask pauses, or to recover from a server crash - Hooks —
beforeTurnto modify history, model, or system prompt between iterations (RAG injection, model routing);transformMessageto reshape how each prompt converts to LLM messages (user/timestamp prefixes, multi-user annotations) - Custom inputs — store arbitrary data (emails, Slack messages) in the prompt alongside an LLM-ready message representation; the raw data persists in history for UI rendering while the messages drive the conversation
- Custom outputs — tools can emit rich outputs (widgets, charts) via
emit()in handlers; each carries raw data for UI rendering and an LLM message representation for conversation context - Conversation —
Conversationclass manages multi-turn history and event wiring; callturn({ input })to get a readyLoopwith full history - Multi-turn — conversation history via
Promptobjects - Prompt caching — cache control breakpoints are injected automatically; supported providers (Anthropic) avoid re-encoding the full conversation each turn;
Usage.cachedTokensreports cache hits - Token budget — set
maxTokenson a prompt to cap total token usage; the loop stops withtoken_budget_exceededstatus when the budget is reached - Token & cost tracking — accumulates usage across iterations; supports provider-specific cost data (e.g., OpenRouter)
- Console logging — opt-in via
AGENTIC_LOG=debugenv var orlogLevelconstructor option; logs loop lifecycle, LLM calls, tool executions to console with structured data - OpenTelemetry — opt-in tracing via
telemetry: { isEnabled: true }; creates spans for loop runs, LLM calls, and tool executions with token counts and timing; requires@opentelemetry/apias optional peer dependency, zero-cost no-op when not configured - Tool timeouts — per-tool or global timeout prevents stalling
- Structured output — optional zod schema for typed, validated final output via
zodResponseFormat - Zod schemas — all types have corresponding zod schemas for validation and OpenAPI generation
- Provider agnostic — uses the OpenAI SDK, works with any compatible API (OpenAI, Ollama, etc.)
Documentation
See the docs directory:
- Architecture — how the loop, sandbox, and compiler interact
- Tools — defining tools with JSON Schema inputs
- Events — streaming events and state snapshots
- Ask — human-in-the-loop input with Temporal-style replay
- Sandbox — QuickJS execution, console.log, type checking
- Hooks & Custom Inputs — beforeTurn hooks, transformMessage, custom input types
- Conversation — multi-turn history and event management
- Lifecycle — prompt status, serialization, stop/resume, ask flow
- Observability — console logging and OpenTelemetry tracing
Example chat
An interactive TUI chat is included for testing (source):
# Local Ollama (default)
pnpm chat
# OpenAI
BASE_URL=https://api.openai.com/v1 API_KEY=sk-... MODEL=gpt-4 pnpm chat
# Any OpenAI-compatible provider
BASE_URL=https://your-provider/v1 API_KEY=... MODEL=... pnpm chat
License
This project is licensed under AGPL-3.0.
Please read this carefully before using this library. AGPL is unusual for a library and has implications you should understand:
- If you use this library in your application (even as a dependency), your entire application must also be licensed under AGPL-3.0. This means you must make the complete source code of your application available to all users.
- This applies to network use. If your application is accessed over a network (e.g., a web service or API), you must provide source code access to anyone who interacts with it — even if you never distribute a binary.
- There is no linking exception. Unlike LGPL, AGPL does not allow you to use this as a library in proprietary software without the copyleft requirements applying to your code.
In short: this library is free to use in open source projects that are themselves AGPL-compatible. If you are building proprietary or closed-source software, you cannot use this library without a separate commercial license.
For commercial licensing inquiries, contact the maintainer.
For contributors, a Contributor License Agreement applies — see CONTRIBUTING.md for details.
Dependencies
Dependencies
| ID | Version |
|---|---|
| ajv | ^8.18.0 |
| eventemitter3 | ^5.0.4 |
| json-schema-to-typescript | ^15.0.4 |
| openai | ^6.34.0 |
| quickjs-emscripten | ^0.32.0 |
| typescript | ^6.0.2 |
| zod | ^4.3.6 |
Development dependencies
| ID | Version |
|---|---|
| @commitlint/cli | ^20.5.0 |
| @commitlint/config-conventional | ^20.5.0 |
| @eslint/eslintrc | ^3.3.5 |
| @eslint/js | ^10.0.1 |
| @semantic-release/changelog | ^6.0.3 |
| @semantic-release/git | ^10.0.1 |
| @types/node | ^25.6.0 |
| @vitest/coverage-v8 | ^4.1.4 |
| conventional-changelog-conventionalcommits | ^9.1.0 |
| eslint | ^10.2.0 |
| eslint-config-prettier | ^10.1.8 |
| eslint-import-resolver-typescript | ^4.4.4 |
| eslint-plugin-import-x | ^4.16.2 |
| eslint-plugin-prettier | ^5.5.5 |
| husky | ^9.1.7 |
| license-checker | ^25.0.1 |
| msw | ^2.13.3 |
| picocolors | ^1.1.1 |
| prettier | ^3.8.3 |
| semantic-release | ^24.2.0 |
| tsx | ^4.21.0 |
| typescript-eslint | ^8.58.2 |
| vitest | ^4.1.4 |
Peer dependencies
| ID | Version |
|---|---|
| @opentelemetry/api | ^1.0.0 |