AI agent framework for Node.js/TypeScript. Streaming LLM conversations with tool execution, hooks, state management, and prompt caching.
Built on the Anthropic SDK + Hono for SSE.
src/
model/ AgentMessage, ContentBlock, ThinkingLevel, MessageRole
event/ AgentEvent (12 types), TokenUsage
tool/ AgentTool, AgentToolResult, ToolCallContext
config/ AgentConfig, AgentLoopConfig, AgentHooks, HookContext
core/ Agent, AgentLoop, PromptBuilder
state/ ConversationStore, ConversationStateManager, Redis, DynamoDB
compaction/ CompactionHook, LlmCompactionStrategy, TokenEstimator
http/ Hono SSE handler
import { Agent, createAgentConfig, InMemoryConversationStore, userMessage } from "@newwave/agent-core";
const agent = new Agent(
createAgentConfig({ systemPrompt: "You are a helpful assistant." }),
new InMemoryConversationStore(),
);
for await (const event of agent.stream({
agentId: "user-1",
message: userMessage("Hello!"),
})) {
if (event.type === "message_update") process.stdout.write(event.delta);
if (event.type === "agent_end") console.log("\nTokens:", event.usage?.inputTokens, "/", event.usage?.outputTokens);
}Define tools with Zod schemas for automatic validation and JSON Schema generation:
import { z } from "zod";
import type { AgentTool } from "@newwave/agent-core";
import { ToolResult } from "@newwave/agent-core";
const searchTool: AgentTool<{ query: string; limit?: number }> = {
name: "search",
label: "Search",
description: "Search the knowledge base for relevant documents",
schema: z.object({
query: z.string().describe("The search query"),
limit: z.number().optional().default(10).describe("Max results"),
}),
async execute(ctx) {
const results = await searchKnowledgeBase(ctx.parameters.query, ctx.parameters.limit);
return ToolResult.success(JSON.stringify(results));
},
};
const agent = new Agent(
createAgentConfig({ tools: [searchTool] }),
store,
);ToolResult.success("Found 3 results") // normal result
ToolResult.success("data", { parsed: true }) // with typed details
ToolResult.error("API rate limited") // error result
ToolResult.terminate("Question sent to user") // stops the agent loopTools can opt out of being included in LLM context on subsequent turns:
const askUserTool: AgentTool = {
name: "ask_user",
description: "Ask the user a question",
excludeFromContext: true, // tool_use/tool_result stripped from future context
schema: z.object({ question: z.string() }),
async execute(ctx) {
return ToolResult.terminate(ctx.parameters.question);
},
};Agent.stream() returns an AsyncGenerator<AgentEvent>. Events match the SSE wire format:
| Event | Key Fields |
|---|---|
agent_start |
|
agent_end |
error?, usage? (TokenUsage) |
turn_start |
turnNumber |
turn_end |
turnNumber |
message_start |
message |
message_update |
delta (text chunk) |
message_end |
message (full AgentMessage) |
thinking_update |
delta (thinking chunk) |
tool_execution_start |
toolUse |
tool_execution_update |
toolUse, update |
tool_execution_end |
toolUse, result |
schedule_fired |
scheduleId, scheduleType |
for await (const event of agent.stream(request)) {
if (event.type === "agent_end" && event.usage) {
console.log(event.usage.model); // "claude-sonnet-4-6"
console.log(event.usage.inputTokens); // 1234
console.log(event.usage.outputTokens); // 567
}
}Customize agent behavior with lifecycle hooks:
import type { AgentHooks } from "@newwave/agent-core";
const hooks: AgentHooks = {
async transformContext(ctx, messages) {
// Inject workspace context before LLM sees messages
return [userMessage("[Context]\n" + await loadContext(ctx.attributes.workspaceId)), ...messages];
},
async beforeToolCall(ctx, toolName, toolUse) {
if (toolName === "delete_data") return { proceed: false, reason: "Blocked by policy" };
return { proceed: true };
},
async afterToolCall(ctx, toolName, toolUse, result) {
// Modify tool result before LLM sees it
return result;
},
};
const agent = new Agent(
createAgentConfig({
loopConfig: { maxTurns: 25, toolExecutionMode: "parallel", hooks, maxToolResultsInContext: 0 },
}),
store,
);Use CompositeAgentHooks to chain multiple hooks:
import { CompositeAgentHooks } from "@newwave/agent-core";
const hooks = new CompositeAgentHooks([contextHook, policyHook, loggingHook]);import { serve } from "@hono/node-server";
import { createStreamHandler } from "@newwave/agent-core";
const app = createStreamHandler(agent);
serve({ fetch: app.fetch, port: 3000 });Endpoints:
POST /api/stream— SSE stream ({ agentId, conversationId?, message, attributes? })POST /api/steer— inject message into running loopPOST /api/follow-up— queue message for after loop completesPOST /api/abort— request loop to stopGET /api/status?agentId=...&conversationId=...— conversation status
Without a ConversationStateManager, the agent is stateless — each stream() call is independent. With one, you get conversation locking, steer, followUp, and abort.
import Redis from "ioredis";
import { RedisConversationStateManager } from "@newwave/agent-core";
const stateManager = new RedisConversationStateManager(new Redis());
const agent = new Agent(config, store, stateManager);Key schema: agent:{agentId}:conv:{conversationId}:status|lock|followup|steer
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDbConversationStateManager } from "@newwave/agent-core";
const stateManager = new DynamoDbConversationStateManager(
new DynamoDBClient({}),
"agent_conversation_state",
);
const agent = new Agent(config, store, stateManager);Table schema:
PK: pk (S) — "agent:{agentId}:conv:{conversationId}"
SK: sk (S) — "status" | "followup:{timestamp}:{id}" | "steer:{timestamp}:{id}"
stream()acquires a lock — if busy, the message is queued as a follow-up- After the inner loop completes, follow-ups are drained (outer loop)
- Steering messages are appended before each LLM call
- Abort is checked at each turn start
createAgentConfig({
systemPrompt: "You are a helpful assistant.", // default
model: "claude-sonnet-4-6", // default
thinkingLevel: ThinkingLevel.OFF, // OFF | LOW | MEDIUM | HIGH | XHIGH
maxTokens: 8192, // default
tools: [],
loopConfig: {
maxTurns: 25,
toolExecutionMode: "parallel", // "parallel" | "sequential"
hooks: {},
maxToolResultsInContext: 0, // 0 = unlimited
},
});import { ThinkingLevel } from "@newwave/agent-core";
const agent = new Agent(
createAgentConfig({ thinkingLevel: ThinkingLevel.HIGH }),
store,
);
for await (const event of agent.stream(request)) {
if (event.type === "thinking_update") console.log("[thinking]", event.delta);
if (event.type === "message_update") console.log(event.delta);
}Enabled by default. The system prompt and last tool definition include cache_control: { type: "ephemeral" }, so Anthropic caches them across turns. Cached reads are 90% cheaper than uncached input tokens.
Automatically summarize old messages when context exceeds a token threshold:
import { CompactionHook, LlmCompactionStrategy, SimpleTokenEstimator, defaultCompactionConfig } from "@newwave/agent-core";
import { CompositeAgentHooks } from "@newwave/agent-core";
const compactionHook = new CompactionHook(
new LlmCompactionStrategy(new SimpleTokenEstimator()),
defaultCompactionConfig(),
new SimpleTokenEstimator(),
);
const agent = new Agent(
createAgentConfig({
loopConfig: { maxTurns: 25, toolExecutionMode: "parallel", hooks: compactionHook, maxToolResultsInContext: 0 },
}),
store,
);Implement the interface for your database:
interface ConversationStore {
appendMessage(agentId: string, conversationId: string, message: AgentMessage): Promise<void>;
loadMessages(agentId: string, conversationId: string): Promise<AgentMessage[]>;
replaceMessages(agentId: string, conversationId: string, messages: AgentMessage[]): Promise<void>;
deleteConversation(agentId: string, conversationId: string): Promise<void>;
listConversationIds(agentId: string): Promise<string[]>;
}InMemoryConversationStore is included for development/testing.
toAnthropicMessages() handles edge cases automatically:
- User messages interleaved between
tool_useandtool_result→ deferred after the pair - Orphaned
tool_usewithout matchingtool_result→ stripped (pair matching) excludeFromContexttools → stripped by tool namemaxToolResultsInContext→ keeps only the last N tool pairs