GoatChain SDK Developer Guide π
A TypeScript SDK for building AI agents with streaming support, tool calling, and middleware pattern.

β οΈ Breaking Change: preToolUse vs permissionRequest
preToolUse is now only for mutating tool calls (modifiedToolCall).
Permission decisions (allow / deny) must be handled in permissionRequest.
Migration
Before (old pattern):
hooks: {
preToolUse: async () => ({ allow: true }),
}
After (current pattern):
hooks: {
permissionRequest: async () => ({ allow: true }),
}
If you need both, use:
preToolUse to rewrite tool arguments/tool call
permissionRequest to allow or block execution
π¦ Installation
pnpm add goatchain
npm install goatchain
bun add goatchain
π― Core Concepts
GoatChain SDK is built around three core components:
- Agent - The main orchestrator that manages the agent loop, middleware, and tools
- Session - A conversation context that handles message history and streaming
- ModelClient - Abstraction layer for LLM providers (OpenAI, Anthropic, etc.)
π Quick Start
Basic Usage
import process from 'node:process'
import { Agent, createModel, createOpenAIAdapter } from 'goatchain'
const model = createModel({
adapter: createOpenAIAdapter({
defaultModelId: 'gpt-4o',
apiKey: process.env.OPENAI_API_KEY!,
}),
})
const agent = new Agent({
name: 'Simple Assistant',
systemPrompt: 'You are a helpful assistant.',
model,
})
const session = await agent.createSession()
session.send('Hello!')
for await (const event of session.receive()) {
if (event.type === 'text_delta') {
process.stdout.write(event.delta)
} else if (event.type === 'done') {
console.log('\nDone:', event.stopReason)
}
}
π‘ Session Management
Session is the core component for managing conversations. It handles message history, state persistence, and event streaming.
Creating Sessions
const session = await agent.createSession()
const session = await agent.createSession({ sessionId: 'my-session-id' })
const session = await agent.createSession({
maxIterations: 10,
requestParams: {
temperature: 0.7,
maxTokens: 2000,
},
})
Session Configuration Options
interface CreateSessionOptions {
sessionId?: string
model?: ModelRef
maxIterations?: number
cwd?: string
messageQueueConfig?: {
autoProcessQueue?: boolean
maxQueueSize?: number
}
requestParams?: {
temperature?: number
maxTokens?: number
topP?: number
}
}
Working Directory (CWD) Configuration
You can set a working directory for the session, which will be automatically applied to all file operation tools (Read, Write, Edit, Glob, Grep, Bash, AstGrepSearch, AstGrepReplace):
const session = await agent.createSession({
cwd: '/path/to/project',
})
const cwd = session.getCwd()
console.log('Current directory:', cwd)
session.setCwd('/path/to/another/project')
session.send('Read the README.md file')
Benefits:
- Automatically sets the working directory for all tools that support it
- Persisted across session saves and restores
- Can be changed at runtime with
session.setCwd()
- Simplifies file path management in multi-project environments
Sending Messages
session.send('What is the weather today?')
session.send('First question')
session.send('Follow-up question')
Send Options
You can pass options to send() to control priority, tool execution, approval, and more:
session.send('Low priority', { priority: 10 })
session.send('High priority', { priority: 1 })
send() returns a message ID that can be used for queue management:
const messageId = session.send('Can be cancelled later')
session.cancelQueuedMessage(messageId)
Message Queue
Session now supports queue-based messaging by default. You can enqueue multiple messages safely even while a previous receive() is running.
const session = await agent.createSession()
session.send('First message')
session.send('Second message')
session.sendBatch([
{ input: 'Task A', priority: 2 },
{ input: 'Task B', priority: 1 },
])
const queue = session.getQueueStatus()
console.log(queue.length, queue.isProcessing)
session.clearQueue()
Manual queue mode:
const session = await agent.createSession({
messageQueueConfig: { autoProcessQueue: false },
})
session.send('Message 1')
session.send('Message 2')
for await (const event of session.receive()) {
}
for await (const event of session.receive()) {
}
Tool context and approval options still work as before:
session.send('Create files and search the web', {
toolContext: {
approval: {
autoApprove: true,
},
},
})
session.send('Analyze the codebase', {
toolContext: {
approval: {
strategy: 'high_risk',
},
},
})
session.setCwd('/path/to/project')
session.send('Read and analyze all TypeScript files', {
toolContext: {
approval: { autoApprove: true },
},
})
Receiving Events
The receive() method returns an async generator that streams events:
for await (const event of session.receive()) {
switch (event.type) {
case 'text_delta':
process.stdout.write(event.delta)
break
case 'tool_call_start':
console.log(`\nCalling tool: ${event.name}`)
break
case 'tool_result':
console.log(`Tool result: ${event.result}`)
break
case 'iteration_end':
console.log(`\nIteration ${event.iteration} complete`)
console.log(`Tokens used: ${event.usage?.totalTokens}`)
break
case 'done':
console.log(`\nConversation done: ${event.stopReason}`)
console.log(`Total tokens: ${event.usage?.totalTokens}`)
break
case 'error':
console.error(`Error: ${event.error}`)
break
}
}
Session Event Types
iteration_start | Agent loop iteration begins | iteration |
text_delta | Partial text response | delta |
thinking_start | Reasoning phase begins | - |
thinking_delta | Reasoning content | delta |
thinking_end | Reasoning phase ends | - |
tool_call_start | Tool invocation begins | name, id |
tool_call_delta | Tool arguments stream | delta |
tool_call_end | Tool call complete | name, args |
tool_result | Tool execution result | result, error |
iteration_end | Iteration complete | usage, iteration |
done | Stream finished | stopReason, usage |
error | Error occurred | error |
Session State Management
console.log(session.messages)
console.log(session.status)
console.log(session.usage)
console.log(session.id)
console.log(session.createdAt)
console.log(session.updatedAt)
Session Persistence
Sessions can be saved and restored. All session state including message history, configuration, and working directory (cwd) are preserved:
const snapshot = session.toSnapshot()
session.restoreFromSnapshot(snapshot)
const restored = await agent.createSession()
restored.restoreFromSnapshot(snapshot)
Multi-turn Conversations
const session = await agent.createSession()
session.send('What is 2 + 2?')
for await (const event of session.receive()) {
if (event.type === 'text_delta') {
process.stdout.write(event.delta)
}
}
session.send('What about multiplying that by 3?')
for await (const event of session.receive()) {
if (event.type === 'text_delta') {
process.stdout.write(event.delta)
}
}
console.log(session.messages.length)
Resuming Sessions
Resume interrupted sessions using checkpoints:
import { FileStateStore } from 'goatchain'
const stateStore = new FileStateStore({
dir: './checkpoints',
deleteOnComplete: true,
})
const agent = new Agent({
name: 'MyAgent',
systemPrompt: 'You are helpful.',
model,
stateStore,
})
const session = await agent.createSession({ sessionId: 'session-123' })
session.send('Start a long task')
for await (const event of session.receive()) {
}
const resumed = await agent.resumeSession('session-123')
for await (const event of resumed.receive()) {
}
Session Lifecycle Hooks
session.addMessage({
role: 'user',
content: 'Custom message',
})
await session.save()
session.messages = []
π€ Agent Configuration
Creating an Agent
import { Agent, createModel, createOpenAIAdapter } from 'goatchain'
const agent = new Agent({
name: 'MyAgent',
systemPrompt: 'You are a helpful assistant.',
model,
tools,
stateStore,
middleware: [],
mcpServers: [],
enableLogging: false,
})
Agent Options
interface AgentOptions {
id?: string
name: string
systemPrompt: string
model: ModelClient
tools?: ToolRegistry
stateStore?: StateStore
middleware?: Middleware[]
mcpServers?: MCPServerConfig[]
enableLogging?: boolean
}
Runtime Model Switching
agent.setModel({ provider: 'openai', modelId: 'gpt-4o-mini' })
const otherModelClient = createModel({ adapter: createOpenAIAdapter({ defaultModelId: 'gpt-4o' }) })
agent.setModel(otherModelClient)
Session Manager
Manage multiple sessions:
const sessionManager = agent.sessionManager
const sessions = await sessionManager.list()
const session = await sessionManager.get('session-id')
await sessionManager.destroy('session-id')
π§ Tool System
Using Built-in Tools
GoatChain SDK exports the following built-in tools:
ReadTool | Read | File | Read file content (text, binary metadata, and selected converted formats) |
WriteTool | Write | File | Create or overwrite files |
EditTool | Edit | File | In-place text replacement edits |
GlobTool | Glob | File/Search | Find files by glob pattern |
GrepTool | Grep | File/Search | Search file contents by pattern |
BashTool | Bash | Command | Execute shell commands |
WebSearchTool | WebSearch | Web | Search the web (e.g. via Serper API) |
WebFetchTool | WebFetch | Web | Fetch and extract content from a specific URL |
TodoWriteTool | TodoWrite | Planning | Manage structured todo lists |
TodoPlanTool | TodoPlan | Planning | Create/update planning todos for plan flows |
AskUserTool | AskUserQuestion | Interaction | Ask the user structured follow-up questions |
EnterPlanModeTool | EnterPlanMode | Mode | Enter plan mode |
ExitPlanModeTool | ExitPlanMode | Mode | Exit plan mode |
import {
Agent,
ToolRegistry,
ReadTool,
WriteTool,
EditTool,
BashTool,
GrepTool,
GlobTool,
WebSearchTool,
WebFetchTool,
} from 'goatchain'
const tools = new ToolRegistry()
tools.register(new ReadTool())
tools.register(new WriteTool())
tools.register(new EditTool())
tools.register(new BashTool())
tools.register(new GrepTool())
tools.register(new GlobTool())
tools.register(new WebSearchTool({ apiKey: process.env.SERPER_API_KEY }))
tools.register(new WebFetchTool())
const agent = new Agent({
name: 'MyAgent',
systemPrompt: 'You are helpful.',
model,
tools,
})
MCP Servers (HTTP + stdio)
GoatChain can connect MCP servers and register their remote tools automatically:
const agent = new Agent({
name: 'MCP Assistant',
systemPrompt: 'You are helpful.',
model,
mcpServers: [
{
id: 'weather',
name: 'Weather API',
transport: 'http',
url: 'https://example.com/mcp',
auth: { type: 'bearer', token: process.env.WEATHER_API_KEY! },
},
{
id: 'local-tools',
name: 'Local Tools',
transport: 'stdio',
command: 'node',
args: ['./mcp-servers/tools.js'],
},
],
})
See docs/mcp.md for details.
Configuring File Tool Working Directory
File-related tools (ReadTool, WriteTool, EditTool, GlobTool, GrepTool, BashTool) support configuring the working directory:
import path from 'node:path'
const OUTPUT_DIR = path.resolve(import.meta.dirname, 'output')
const tools = new ToolRegistry()
tools.register(new ReadTool({ cwd: OUTPUT_DIR }))
tools.register(new WriteTool({ cwd: OUTPUT_DIR }))
tools.register(new EditTool({ cwd: OUTPUT_DIR }))
tools.register(new GlobTool({ cwd: OUTPUT_DIR }))
tools.register(new GrepTool({ cwd: OUTPUT_DIR }))
tools.register(new BashTool({ cwd: OUTPUT_DIR }))
const tools = new ToolRegistry()
tools.register(
new ReadTool({
cwd: OUTPUT_DIR,
allowedDirectory: OUTPUT_DIR,
}),
)
tools.register(
new WriteTool({
cwd: OUTPUT_DIR,
allowedDirectory: OUTPUT_DIR,
}),
)
How each file tool uses cwd:
ReadTool | Reads files (and some converted formats) | Relative file_path resolves from cwd | file_path can be absolute | allowedDirectory, fileBlacklist, disableBlacklist |
WriteTool | Writes/overwrites files | Relative file_path resolves from cwd | file_path can be absolute | allowedDirectory, fileBlacklist, disableBlacklist |
EditTool | Replaces old_string with new_string in a file | Relative file_path resolves from cwd | file_path can be absolute | fileBlacklist, disableBlacklist |
GlobTool | Finds files by pattern | Search root defaults to cwd | path argument can change search root | fileBlacklist, disableBlacklist |
GrepTool | Searches text content in files | Search runs under cwd | path argument narrows search scope | fileBlacklist, disableBlacklist |
BashTool | Runs shell commands | Commands execute in cwd | workdir argument overrides per call | None |
Directory & Protection Options:
cwd | Working directory for resolving relative paths | { cwd: '/app/output' } |
allowedDirectory | Restrict file access to this directory only (blocks path traversal) | { allowedDirectory: '/app/output' } |
When to use allowedDirectory:
- When you want to sandbox the agent to a specific directory
- To prevent accidental access to sensitive files
- For production environments with security requirements
Creating Custom Tools
import { BaseTool, ToolRegistry } from 'goatchain'
class MyCustomTool extends BaseTool {
name = 'my_tool'
description = 'Does something useful'
parameters = {
type: 'object',
properties: {
input: {
type: 'string',
description: 'Input parameter',
},
},
required: ['input'],
}
async execute(args: { input: string }) {
return `Processed: ${args.input}`
}
}
const tools = new ToolRegistry()
tools.register(new MyCustomTool())
const agent = new Agent({
name: 'MyAgent',
systemPrompt: 'You are helpful.',
model,
tools,
})
Tool Registry
Dynamically manage tools:
const registry = agent.tools
registry.register(new MyCustomTool())
registry.unregister('my_tool')
const tool = registry.get('my_tool')
const allTools = registry.list()
const openaiTools = registry.toOpenAIFormat()
π£ Tool Approval & Hooks
Sessions support lifecycle hooks that let you intercept user input, tool calls, and session/subagent lifecycle events.
Key Concepts
GoatChain has three relevant mechanisms for tool execution control:
-
preToolUse Hook - For tool-call mutation before permission/approval
- Can modify tool name/arguments with
modifiedToolCall
- Runs before
permissionRequest
-
permissionRequest Hook - For programmatic auto-approval/blocking
- Runs after
preToolUse, so it sees the modified tool call
allow: true β skips approval flow (execution still goes through normal middleware/disabled checks)
allow: false β tool is blocked
- Use this for automated scenarios where you want to programmatically approve/deny tools
-
Approval System (via toolContext.approval) - For interactive user approval
- Pauses execution on high-risk tools
- Shows
requires_action event
- Resumes with user's approval decisions
- Use this for interactive UIs where users manually approve tools
These are independent: If permissionRequest returns allow: true, the approval system is bypassed.
Hook Types
interface AgentHooks {
sessionStart?: (ctx: SessionStartContext) => Promise<void>
sessionEnd?: (ctx: SessionEndContext) => Promise<void>
stop?: (ctx: StopContext) => Promise<void>
userPromptSubmit?:
| ((ctx: UserPromptSubmitContext) => Promise<UserPromptSubmitResult>)
| PromptHookEntry
| Array<((ctx: UserPromptSubmitContext) => Promise<UserPromptSubmitResult>) | PromptHookEntry>
preToolUse?:
| ((ctx: ToolHookContext) => Promise<PreToolUseResult | void>)
| PromptHookEntry
| Array<((ctx: ToolHookContext) => Promise<PreToolUseResult | void>) | PromptHookEntry>
permissionRequest?:
| ((ctx: ToolHookContext) => Promise<PermissionRequestResult>)
| PromptHookEntry
| Array<((ctx: ToolHookContext) => Promise<PermissionRequestResult>) | PromptHookEntry>
postToolUse?:
| ((ctx: ToolHookContext, result: unknown) => Promise<void>)
| PromptHookEntry
| Array<((ctx: ToolHookContext, result: unknown) => Promise<void>) | PromptHookEntry>
postToolUseFailure?:
| ((ctx: ToolHookContext, error: Error) => Promise<void>)
| PromptHookEntry
| Array<((ctx: ToolHookContext, error: Error) => Promise<void>) | PromptHookEntry>
subagentStart?: (ctx: SubagentStartContext) => Promise<void>
subagentStop?:
| ((ctx: SubagentStopContext) => Promise<void>)
| PromptHookEntry
| Array<((ctx: SubagentStopContext) => Promise<void>) | PromptHookEntry>
}
type ToolHooks = AgentHooks
interface PromptHookEntry {
type: 'prompt'
prompt: string
model?: { provider: string; modelId: string }
timeoutMs?: number
}
interface BaseHookContext {
sessionId: string
}
interface ToolHookContext extends BaseHookContext {
toolCall: {
id: string
type: 'function'
function: {
name: string
arguments: string
}
}
toolContext: ToolExecutionContext
}
interface PermissionRequestResult {
allow: boolean
modifiedToolCall?: ToolCall
}
interface PreToolUseResult {
modifiedToolCall?: ToolCall
}
interface UserPromptSubmitResult {
allow: boolean
modifiedInput?: MessageContent
}
interface SessionStartContext extends BaseHookContext {
startReason: 'new' | 'resume'
messages: Message[]
}
interface StopContext extends BaseHookContext {
stopReason:
| 'max_iterations'
| 'final_response'
| 'error'
| 'cancelled'
| 'approval_required'
| 'max_follow_ups'
modelStopReason?: 'tool_call' | 'final' | 'length' | 'error' | 'cancelled'
finalResponse?: string
usage: Usage
error?: { code?: string; message: string }
messages: Message[]
}
interface SessionEndContext extends BaseHookContext {
stopReason: StopContext['stopReason']
finalResponse?: string
usage: Usage
durationMs: number
error?: { code?: string; message: string }
messages: Message[]
}
interface UserPromptSubmitContext extends BaseHookContext {
input: MessageContent
}
interface SubagentStartContext extends BaseHookContext {
subagentId: string
subagentType: string
taskDescription?: string
prompt: string
}
interface SubagentStopContext extends BaseHookContext {
subagentId: string
subagentType: string
result?: unknown
error?: Error
durationMs: number
usage?: Usage
messages: Message[]
}
Prompt Hook Evaluation
Prompt hooks are evaluation-only in current SDK behavior:
- Prompt evaluation does not change execution decisions or mutate input/tool calls
- Supported prompt hooks:
userPromptSubmit, preToolUse, permissionRequest, postToolUse, postToolUseFailure, subagentStop
permissionRequest prompt evaluation only runs when the approval path is entered
- Each evaluation is persisted in
session.metadata._hookEvaluations
Prompt evaluation emits hook_evaluation stream events with phase:
start
stream (text delta)
end (status/result/error)
hook_evaluation event shape:
interface HookEvaluationEvent extends BaseEvent {
type: 'hook_evaluation'
evaluationId: string
hookName:
| 'permissionRequest'
| 'preToolUse'
| 'postToolUse'
| 'postToolUseFailure'
| 'subagentStop'
| 'userPromptSubmit'
phase: 'start' | 'stream' | 'end'
prompt?: string
input?: unknown
delta?: string
rawResponse?: string
result?: unknown
usage?: Usage
durationMs?: number
status?: 'success' | 'error' | 'timeout'
error?: { code?: string; message: string }
toolCallId?: string
}
Metadata persistence shape:
session.metadata._hookEvaluations = {
preToolUse: [
{
evaluationId: '...',
timestamp: 1730000000000,
hookName: 'preToolUse',
prompt: '...',
input: { ... },
status: 'success',
durationMs: 120,
rawResponse: '{"ok":true}',
result: { ok: true },
usage: { promptTokens: 100, completionTokens: 20, totalTokens: 120 },
toolCallId: 'call_123',
},
],
}
Complete prompt hook example (function hook + prompt hook + event handling):
import { Agent } from 'goatchain'
import type { HookEvaluationEvent } from 'goatchain'
const session = await agent.createSession({
hooks: {
preToolUse: [
async (ctx) => {
return undefined
},
{
type: 'prompt',
prompt: 'Analyze this tool call: $ARGUMENTS',
},
],
permissionRequest: {
type: 'prompt',
prompt: 'Review approval context: $ARGUMENTS',
},
},
})
session.send('Do the task', {
toolContext: {
approval: { strategy: 'high_risk' },
},
})
for await (const event of session.receive()) {
if (event.type === 'hook_evaluation') {
const ev = event as HookEvaluationEvent
if (ev.phase === 'end') {
console.log('hook evaluation done:', ev.hookName, ev.status, ev.result)
}
}
}
console.log(session.metadata?._hookEvaluations)
Hook Execution Order
Typical order in one run:
sessionStart (once, on the first receive() for this session)
userPromptSubmit (before a user message enters the loop; can block or rewrite input)
preToolUse (runs first for each tool call; can rewrite tool call)
permissionRequest (runs after preToolUse; allow/block decision before approval check)
- Approval flow (
toolContext.approval) if still required
postToolUse or postToolUseFailure
stop (after each LLM response completes; for no-LLM termination points, it runs before done)
sessionEnd (when a run fully finishes; not emitted at approval_required pause)
Basic Hook Usage
import { Agent, ToolRegistry, ReadTool, WriteTool } from 'goatchain'
const agent = new Agent({
name: 'MyAgent',
systemPrompt: 'You are helpful.',
model,
tools: new ToolRegistry().register(new ReadTool()).register(new WriteTool()),
})
const session = await agent.createSession({
hooks: {
preToolUse: async (ctx) => {
const toolName = ctx.toolCall.function.name
console.log(`PreToolUse: ${toolName}`)
},
permissionRequest: async (ctx) => {
const toolName = ctx.toolCall.function.name
console.log(`Tool requested: ${toolName}`)
return { allow: true }
},
postToolUse: async (ctx, result) => {
console.log(`Tool ${ctx.toolCall.function.name} completed successfully`)
},
postToolUseFailure: async (ctx, error) => {
console.error(`Tool ${ctx.toolCall.function.name} failed:`, error)
},
},
})
Auto-Approval with permissionRequest Hook
The permissionRequest hook is powerful because allow: true bypasses the approval flow entirely, even for high-risk tools. This is useful for automated scenarios:
import type { RiskLevel } from 'goatchain'
function shouldAutoApprove(toolName: string, riskLevel: RiskLevel): boolean {
if (riskLevel === 'safe' || riskLevel === 'low') {
return true
}
if (riskLevel === 'critical') {
return false
}
return process.env.AUTO_APPROVE_ALL === 'true'
}
const session = await agent.createSession({
hooks: {
permissionRequest: async (ctx) => {
const tool = agent.tools.get(ctx.toolCall.function.name)
const riskLevel = tool?.riskLevel ?? 'safe'
const allow = shouldAutoApprove(ctx.toolCall.function.name, riskLevel)
if (allow) {
console.log(`β Auto-approved: ${ctx.toolCall.function.name}`)
} else {
console.log(`β Blocked: ${ctx.toolCall.function.name}`)
}
return { allow }
},
},
})
Auto-Approve All Tools with toolContext
The simplest way to bypass approval for a specific request is using toolContext.approval.autoApprove:
session.send('Create a file and search the web', {
toolContext: {
approval: {
autoApprove: true,
},
},
})
for await (const event of session.receive()) {
if (event.type === 'text_delta') {
process.stdout.write(event.delta)
}
}
Use cases:
- Automated workflows: Scripts that run without user interaction
- Testing: E2E tests that need tools to execute automatically
- Trusted environments: When you trust the agent's tool usage completely
Approval strategies:
session.send('Your task', {
toolContext: {
approval: {
autoApprove: true,
strategy: 'high_risk',
strategy: 'all',
},
},
})
Interactive Approval with Pause/Resume
Important: The permissionRequest hook with allow: true skips approval. For interactive approval flows where you want to pause and ask the user, use toolContext.approval instead:
import type { AgentLoopCheckpoint } from 'goatchain'
const session = await agent.createSession()
let checkpoint: AgentLoopCheckpoint | undefined
session.send('Create a file and delete it', {
toolContext: {
approval: { strategy: 'high_risk' },
},
})
for await (const event of session.receive()) {
if (event.type === 'requires_action') {
checkpoint =
event.checkpoint ||
(await agent.stateStore?.loadCheckpoint(event.checkpointRef?.sessionId))
break
}
}
if (checkpoint) {
const decisions = Object.fromEntries(
checkpoint.pendingToolCalls.map((pending) => {
const toolName = pending.toolCall.function.name
const approved = confirm(`Approve ${toolName}?`)
return [
pending.toolCall.id,
{ approved, reason: approved ? undefined : 'User denied' },
]
}),
)
for await (const event of session.receive({
toolContext: {
approval: { decisions },
},
})) {
}
}
Complete Example: Interactive Approval System
See examples/tool-approval-session.ts for a full example demonstrating:
- Sync approval - Real-time approval during tool execution
- Async approval - Pause/resume pattern for user interaction
- Blocked tools - Denying high-risk tools automatically
- Risk-based policies - Different approval rules per risk level
bun run examples/tool-approval-session.ts
Key features shown:
- Creating custom tools with risk levels
- Implementing approval hooks with async delays
- Pausing execution on
requires_action events
- Resuming sessions with approval decisions
- Pretty-printed logging with colors and symbols
Modifying Tool Calls with preToolUse
The preToolUse hook can also modify tool calls before execution:
const session = await agent.createSession({
hooks: {
preToolUse: async (ctx) => {
const toolName = ctx.toolCall.function.name
if (toolName === 'Write') {
const args = JSON.parse(ctx.toolCall.function.arguments)
const modifiedToolCall = {
...ctx.toolCall,
function: {
...ctx.toolCall.function,
arguments: JSON.stringify({
...args,
file_path: `/safe-dir/${args.file_path}`,
}),
},
}
return {
modifiedToolCall,
}
}
},
},
})
Tool Context
The toolContext parameter in send() and receive() allows passing additional context:
session.send('Do something risky', {
toolContext: {
approval: {
strategy: 'high_risk',
decisions: {
tool_call_id_123: { approved: true },
tool_call_id_456: { approved: false, reason: 'Too dangerous' },
},
},
custom: { userId: '123', environment: 'production' },
},
})
π§
Middleware System
GoatChain uses a Koa-style onion model for middleware. Each middleware wraps around the core execution:
outer:before β inner:before β exec (model.stream) β inner:after β outer:after
Adding Middleware
agent.use(async (state, next) => {
const start = Date.now()
console.log(`[${state.iteration}] Before model call`)
const nextState = await next(state)
console.log(`[${state.iteration}] After model call (${Date.now() - start}ms)`)
return nextState
}, 'logging')
agent.removeMiddleware('logging')
console.log(agent.middlewareNames)
const unsubscribe = agent.use(middleware, 'temp')
unsubscribe()
Built-in Middleware
Plan Mode Middleware
Adds planning phase before execution:
import { createPlanModeMiddleware } from 'goatchain'
agent.use(createPlanModeMiddleware())
agent.use(
createPlanModeMiddleware({
name: 'my-plan',
planPrompt: 'Create a detailed plan...',
}),
)
Context Compression Middleware
Automatically compresses context when token limit is reached using a two-stage strategy:
import { createContextCompressionMiddleware } from 'goatchain'
agent.use(
createContextCompressionMiddleware({
maxTokens: 128000,
protectedTurns: 2,
model: model,
stateStore: agent.stateStore,
toolCompressionTarget: 0.45,
minKeepToolResults: 5,
enableLogging: true,
logFilePath: 'compression-logs.jsonl',
}),
)
See Context Compression Logging Guide for details on monitoring compression behavior.
Custom Middleware Examples
Logging Middleware
agent.use(async (state, next) => {
console.log(`Iteration ${state.iteration}:`, {
messages: state.messages.length,
pendingTools: state.pendingToolCalls.length,
})
const result = await next(state)
console.log(`Completed iteration ${state.iteration}:`, {
shouldContinue: result.shouldContinue,
usage: result.usage,
})
return result
}, 'logger')
Error Handling Middleware
agent.use(async (state, next) => {
try {
return await next(state)
} catch (error) {
console.error('Agent error:', error)
state.shouldContinue = false
state.stopReason = 'error'
state.error = error
return state
}
}, 'error-handler')
Rate Limiting Middleware
import { RateLimiter } from 'some-rate-limiter'
const limiter = new RateLimiter({ requestsPerMinute: 60 })
agent.use(async (state, next) => {
await limiter.acquire()
return next(state)
}, 'rate-limiter')
Custom Retry Middleware
agent.use(async (state, next) => {
let retries = 3
while (retries > 0) {
try {
return await next(state)
} catch (error) {
retries--
if (retries === 0) throw error
console.log(`Retrying... (${retries} attempts left)`)
await new Promise((resolve) => setTimeout(resolve, 1000))
}
}
return state
}, 'retry')
Middleware State
The AgentLoopState object passed to middleware:
interface AgentLoopState {
sessionId: string
messages: Message[]
iteration: number
pendingToolCalls: ToolCallWithResult[]
currentResponse: string
shouldContinue: boolean
stopReason?: string
usage?: Usage
error?: Error
}
π Model Client
Creating a Model Client
import { createModel, createOpenAIAdapter } from 'goatchain'
const model = createModel({
adapter: createOpenAIAdapter({
defaultModelId: 'gpt-4o',
apiKey: process.env.OPENAI_API_KEY!,
baseUrl: 'https://api.openai.com/v1',
organization: 'org-xxx',
}),
})
OpenAI Adapter Options
interface OpenAIAdapterOptions {
defaultModelId?: string
apiKey?: string
baseUrl?: string
organization?: string
defaultHeaders?: Record<string, string>
timeout?: number
maxRetries?: number
}
Using Different Models
const openai = createModel({
adapter: createOpenAIAdapter({
defaultModelId: 'gpt-4o',
apiKey: process.env.OPENAI_API_KEY!,
}),
})
const deepseek = createModel({
adapter: createOpenAIAdapter({
defaultModelId: 'deepseek-chat',
apiKey: process.env.DEEPSEEK_API_KEY!,
baseUrl: 'https://api.deepseek.com/v1',
}),
})
Model Interface
Implement custom model adapters:
interface ModelClient {
modelId: string
stream(request: ModelRequest): AsyncIterable<ModelStreamEvent>
run?(request: ModelRequest): Promise<ModelRunResult>
}
interface ModelRequest {
messages: Message[]
tools?: OpenAITool[]
temperature?: number
maxTokens?: number
topP?: number
stopSequences?: string[]
}
πΎ State Management
File State Store
Persist agent state to filesystem:
import { FileStateStore } from 'goatchain'
const stateStore = new FileStateStore({
dir: './checkpoints',
deleteOnComplete: true,
})
const agent = new Agent({
name: 'MyAgent',
systemPrompt: 'You are helpful.',
model,
stateStore,
})
In-Memory State Store
For testing or temporary state:
import { InMemoryStateStore } from 'goatchain'
const stateStore = new InMemoryStateStore({
deleteOnComplete: false,
})
State Store Interface
Implement custom state stores:
interface StateStore {
deleteOnComplete: boolean
saveCheckpoint(checkpoint: AgentLoopCheckpoint): Promise<void>
loadCheckpoint(sessionId: string): Promise<AgentLoopCheckpoint | null>
deleteCheckpoint(sessionId: string): Promise<void>
listCheckpoints(): Promise<AgentLoopCheckpoint[]>
}
interface AgentLoopCheckpoint {
sessionId: string
messages: Message[]
iteration: number
usage: Usage
createdAt: number
updatedAt: number
}
Manual Checkpoint Management
await stateStore.saveCheckpoint({
sessionId: session.id,
messages: session.messages,
iteration: 3,
usage: session.usage,
createdAt: Date.now(),
updatedAt: Date.now(),
})
const checkpoint = await stateStore.loadCheckpoint('session-id')
const checkpoints = await stateStore.listCheckpoints()
await stateStore.deleteCheckpoint('session-id')
π Complete Examples
Example 1: Simple Q&A Bot
import process from 'node:process'
import { Agent, createModel, createOpenAIAdapter } from 'goatchain'
const model = createModel({
adapter: createOpenAIAdapter({
defaultModelId: 'gpt-4o',
apiKey: process.env.OPENAI_API_KEY!,
}),
})
const agent = new Agent({
name: 'Q&A Bot',
systemPrompt: 'You are a helpful assistant that answers questions concisely.',
model,
})
const session = await agent.createSession()
session.send('What is the capital of France?')
for await (const event of session.receive()) {
if (event.type === 'text_delta') {
process.stdout.write(event.delta)
}
}
Example 2: Agent with Tools
import {
Agent,
createModel,
createOpenAIAdapter,
ToolRegistry,
ReadTool,
WriteTool,
BashTool,
} from 'goatchain'
const model = createModel({
adapter: createOpenAIAdapter({
defaultModelId: 'gpt-4o',
apiKey: process.env.OPENAI_API_KEY!,
}),
})
const tools = new ToolRegistry()
tools.register(new ReadTool())
tools.register(new WriteTool())
tools.register(new BashTool())
const agent = new Agent({
name: 'File Assistant',
systemPrompt: 'You help users manage their files.',
model,
tools,
})
const session = await agent.createSession()
session.send('Read the package.json file and tell me the version')
for await (const event of session.receive()) {
if (event.type === 'text_delta') {
process.stdout.write(event.delta)
} else if (event.type === 'tool_call_start') {
console.log(`\nCalling: ${event.name}`)
} else if (event.type === 'tool_result') {
console.log(`Result: ${JSON.stringify(event.result).slice(0, 100)}...`)
}
}
Example 3: Persistent Sessions
import {
Agent,
createModel,
createOpenAIAdapter,
FileStateStore,
} from 'goatchain'
const model = createModel({
adapter: createOpenAIAdapter({
defaultModelId: 'gpt-4o',
apiKey: process.env.OPENAI_API_KEY!,
}),
})
const stateStore = new FileStateStore({
dir: './agent-state',
deleteOnComplete: false,
})
const agent = new Agent({
name: 'Persistent Agent',
systemPrompt: 'You are a helpful assistant.',
model,
stateStore,
})
let session
const sessionId = 'my-conversation'
try {
session = await agent.resumeSession(sessionId)
console.log('Resumed existing session')
} catch {
session = await agent.createSession({ sessionId })
console.log('Created new session')
}
session.send('Remember this: My favorite color is blue')
for await (const event of session.receive()) {
if (event.type === 'text_delta') {
process.stdout.write(event.delta)
}
}
Example 4: Session with Middleware
import {
Agent,
createModel,
createOpenAIAdapter,
createPlanModeMiddleware,
} from 'goatchain'
const model = createModel({
adapter: createOpenAIAdapter({
defaultModelId: 'gpt-4o',
apiKey: process.env.OPENAI_API_KEY!,
}),
})
const agent = new Agent({
name: 'Planning Agent',
systemPrompt: 'You are a helpful assistant.',
model,
})
agent.use(async (state, next) => {
console.log(`\n=== Iteration ${state.iteration} ===`)
const result = await next(state)
console.log(`Tokens used: ${result.usage?.totalTokens || 0}`)
return result
}, 'logger')
agent.use(createPlanModeMiddleware())
const session = await agent.createSession()
session.send('Create a todo list app with React and TypeScript')
for await (const event of session.receive()) {
if (event.type === 'text_delta') {
process.stdout.write(event.delta)
}
}
Example 5: Multi-turn Conversation
import { Agent, createModel, createOpenAIAdapter } from 'goatchain'
const model = createModel({
adapter: createOpenAIAdapter({
defaultModelId: 'gpt-4o',
apiKey: process.env.OPENAI_API_KEY!,
}),
})
const agent = new Agent({
name: 'Conversational Agent',
systemPrompt: 'You are a helpful assistant.',
model,
})
const session = await agent.createSession()
async function chat(message: string) {
console.log(`\nUser: ${message}`)
console.log('Assistant: ')
session.send(message)
for await (const event of session.receive()) {
if (event.type === 'text_delta') {
process.stdout.write(event.delta)
}
}
console.log('\n')
}
await chat('My name is Alice')
await chat('What is 2 + 2?')
await chat('What is my name?')
await chat('Multiply the previous result by 3')
console.log(`Total messages: ${session.messages.length}`)
console.log(`Total tokens: ${session.usage.totalTokens}`)
Example 6: Working Directory with Auto-Approval
Combine session-level working directory with auto-approval for automated file operations:
import {
Agent,
createModel,
createOpenAIAdapter,
createBuiltinTools,
} from 'goatchain'
const model = createModel({
adapter: createOpenAIAdapter({
defaultModelId: 'gpt-4o',
apiKey: process.env.OPENAI_API_KEY!,
}),
})
const agent = new Agent({
name: 'File Agent',
systemPrompt: 'You are a file management assistant.',
model,
tools: createBuiltinTools(),
})
const session = await agent.createSession({
cwd: '/path/to/project',
})
session.send('List all TypeScript files, then create a summary.md file', {
toolContext: {
approval: {
autoApprove: true,
},
},
})
for await (const event of session.receive()) {
if (event.type === 'text_delta') {
process.stdout.write(event.delta)
} else if (event.type === 'tool_call_start') {
console.log(`\nExecuting: ${event.toolName}`)
}
}
session.setCwd('/path/to/another/project')
session.send('Analyze the project structure and create a report', {
toolContext: {
approval: { autoApprove: true },
},
})
for await (const event of session.receive()) {
if (event.type === 'text_delta') {
process.stdout.write(event.delta)
}
}
Perfect for:
- CI/CD pipelines that need file analysis
- Automated code review bots
- Project documentation generators
- Bulk file operations without manual intervention
Example 7: Tool-level Working Directory (Advanced)
For more control, you can configure individual tools with specific directories and restrictions:
import path from 'node:path'
import {
Agent,
createModel,
createOpenAIAdapter,
ToolRegistry,
ReadTool,
WriteTool,
GlobTool,
GrepTool,
} from 'goatchain'
const model = createModel({
adapter: createOpenAIAdapter({
defaultModelId: 'gpt-4o',
apiKey: process.env.OPENAI_API_KEY!,
}),
})
const OUTPUT_DIR = path.resolve(process.cwd(), 'output')
const tools = new ToolRegistry()
tools.register(
new ReadTool({
cwd: OUTPUT_DIR,
allowedDirectory: OUTPUT_DIR,
}),
)
tools.register(
new WriteTool({
cwd: OUTPUT_DIR,
allowedDirectory: OUTPUT_DIR,
}),
)
tools.register(new GlobTool({ cwd: OUTPUT_DIR }))
tools.register(new GrepTool({ cwd: OUTPUT_DIR }))
const agent = new Agent({
name: 'Sandboxed File Agent',
systemPrompt: `You are a file management assistant.
All your file operations are restricted to the output directory.
You can create, read, and modify files within this sandbox.`,
model,
tools,
})
const session = await agent.createSession()
session.send("Create a report.md file with a summary of today's tasks")
for await (const event of session.receive()) {
if (event.type === 'text_delta') {
process.stdout.write(event.delta)
} else if (event.type === 'tool_call_start') {
console.log(`\n[Tool] ${event.name}`)
}
}
π API Reference
Agent Class
Constructor
new Agent(options: AgentOptions)
Options:
name: string - Agent name (required)
systemPrompt: string - System instructions (required)
model: ModelClient - LLM client (required)
tools?: ToolRegistry - Tool registry (not an array)
stateStore?: StateStore - Persistence layer
middleware?: Middleware[] - Middleware list to register at startup
mcpServers?: MCPServerConfig[] - MCP server configuration list
enableLogging?: boolean - Enable internal logs
Methods
createSession(options?): Promise<Session>
Create a new session.
const session = await agent.createSession({
sessionId: 'custom-id',
maxIterations: 10,
requestParams: {
temperature: 0.7,
maxTokens: 2000,
},
})
resumeSession(sessionId, options?): Promise<Session>
Resume an existing session from checkpoint.
const session = await agent.resumeSession('session-123')
use(middleware, name?): Promise<() => void>
Add middleware. Returns unsubscribe function.
const unsubscribe = await agent.use(myMiddleware, 'my_middleware')
unsubscribe()
removeMiddleware(nameOrFn): boolean
Remove middleware by name or function reference.
agent.removeMiddleware('my_middleware')
setModel(modelOrRef): void
Switch or pin model at runtime.
agent.setModel({ provider: 'openai', modelId: 'gpt-4o-mini' })
Properties
id: string - Agent ID
name: string - Agent name
systemPrompt: string - System prompt
model: ModelClient - Current model client
tools?: ToolRegistry - Tool registry
stateStore?: StateStore - State store
sessionManager?: BaseSessionManager - Session manager
middlewareNames: string[] - List of middleware names
Session Class
Methods
send(input, options?): string
Enqueue a message and return its queue message ID.
const id = session.send('Hello!')
sendBatch(messages): string[]
Batch enqueue messages and return queue message IDs.
const ids = session.sendBatch([
{ input: 'task-1', priority: 1 },
{ input: 'task-2', priority: 2 },
])
cancelQueuedMessage(messageId): boolean
Cancel a queued message by ID.
session.cancelQueuedMessage(id)
getQueueStatus(): MessageQueueStatus
Query queue length, preview list, processing status, and config.
console.log(session.getQueueStatus())
receive(options?): AsyncGenerator<AgentEvent>
Stream agent events.
for await (const event of session.receive()) {
console.log(event)
}
addMessage(message): void
Manually add a message.
session.addMessage({
role: 'user',
content: 'Hello',
})
save(): Promise<void>
Manually save session state.
await session.save()
toSnapshot(): SessionSnapshot
Export session to snapshot.
const snapshot = session.toSnapshot()
restoreFromSnapshot(snapshot): void
Restore session from snapshot.
getCwd(): string | undefined
Get the current working directory for this session.
const cwd = session.getCwd()
console.log('Working directory:', cwd)
setCwd(cwd: string): void
Set the current working directory for this session. This automatically syncs the new directory to all tools that support it.
session.setCwd('/path/to/project')
Properties
id: string - Session ID
status: SessionStatus - Session status ('idle' | 'running' | 'completed' | 'error')
messages: Message[] - Message history
usage: Usage - Token usage statistics
createdAt: number - Creation timestamp
updatedAt: number - Last update timestamp
Message Type
interface Message {
role: 'system' | 'user' | 'assistant' | 'tool'
content: string | ToolCall[] | ToolResult[]
name?: string
toolCallId?: string
}
Usage Type
interface Usage {
promptTokens: number
completionTokens: number
totalTokens: number
}
AgentEvent Types
type AgentEvent =
| TextDeltaEvent
| ToolCallStartEvent
| ToolCallDeltaEvent
| ToolCallEndEvent
| ToolResultEvent
| ThinkingStartEvent
| ThinkingDeltaEvent
| ThinkingEndEvent
| IterationStartEvent
| IterationEndEvent
| DoneEvent
| ErrorEvent
interface TextDeltaEvent {
type: 'text_delta'
delta: string
}
interface ToolCallStartEvent {
type: 'tool_call_start'
id: string
name: string
}
interface ToolResultEvent {
type: 'tool_result'
tool_call_id: string
result: unknown
isError?: boolean
}
interface DoneEvent {
type: 'done'
stopReason:
| 'max_iterations'
| 'final_response'
| 'error'
| 'cancelled'
| 'approval_required'
| 'max_follow_ups'
usage?: Usage
}
ποΈ Architecture
classDiagram
direction TB
class Agent {
+id: string
+name: string
+systemPrompt: string
+model: ModelClient
+tools: ToolRegistry?
+stateStore: StateStore?
+sessionManager: BaseSessionManager?
+use(middleware): Promise~function~
+createSession(): Promise~Session~
+resumeSession(id): Promise~Session~
}
class ModelClient {
<<interface>>
+modelId: string
+stream(request): AsyncIterable~ModelStreamEvent~
}
class StateStore {
<<interface>>
+saveCheckpoint(): Promise~void~
+loadCheckpoint(): Promise~Checkpoint~
+deleteCheckpoint(): Promise~void~
+listCheckpoints(): Promise~Checkpoint[]~
}
class BaseTool {
<<abstract>>
+name: string
+description: string
+parameters: JSONSchema
+execute(args): Promise~unknown~
}
class ToolRegistry {
+register(tool): void
+unregister(name): boolean
+get(name): BaseTool
+list(): BaseTool[]
}
class BaseSession {
<<abstract>>
+id: string
+status: SessionStatus
+messages: Message[]
+usage: Usage
+send(input, options?): string
+receive(): AsyncGenerator~AgentEvent~
+save(): Promise~void~
}
class Middleware {
<<function>>
(state, next) => Promise~AgentLoopState~
}
Agent --> ModelClient : uses
Agent --> ToolRegistry : uses
Agent --> StateStore : uses
Agent --> BaseSession : creates
Agent ..> Middleware : applies
ToolRegistry --> BaseTool : contains
π§° Additional Tools
CLI
DimCode includes a terminal UI (TUI) for interactive agent sessions:
npm install -g dimcode@latest
dim
Run the local API server + Web GUI:
dim server --open
Features:
- Interactive chat interface
- Session management
- Tool approval system
- Settings configuration
See docs/cli.md and docs/server.md for details.
ACP Server
Expose DimCode as an Agent Client Protocol server for editor integrations:
dim acp
For source checkouts, use a cwd-independent command:
node /absolute/path/to/GoatChain/scripts/acpx-agent.mjs
Configuration for Zed (settings.json):
{
"agent_servers": {
"dimcode": {
"command": "/absolute/path/to/dim",
"args": ["acp"]
}
}
}
For OpenClaw acpx, use either /absolute/path/to/dim acp or node /absolute/path/to/GoatChain/scripts/acpx-agent.mjs.
Do not use bun run acp-server there; it depends on the launcher cwd being the GoatChain repo root.
See docs/acp-server.md for details.
π Documentation