@arasandev/harness
v0.1.0
Published
A clean-room TypeScript agent harness — vendor-neutral, composable kernel for building agentic systems. Inspired by general-purpose agent research; optimized for Anthropic-compatible endpoints.
Maintainers
Readme
@arasandev/harness v0.0.1
A clean-room TypeScript agent harness — vendor-neutral, composable kernel for building agentic systems. Implements the loop, tool execution, permission gating, hooks, subagent delegation, compaction, and transcript persistence as a typed library you import — not a subprocess you spawn.
Vendor-neutral: Works with any Anthropic-compatible API endpoint (Anthropic, MiniMax, OpenRouter, custom). Strictly typed: No any, Zod validation on all tool inputs, required parameters enforced. Production-ready: 1657 tests, comprehensive error messages, hooks as composable algebra.
Inspired by general-purpose agent research; optimized for the {baseURL, apiKey, model} configuration triad.
Installation
npm install @arasandev/harness @anthropic-ai/sdk zodEnvironment Setup
The SDK reads three environment variables (all optional if passed explicitly):
ANTHROPIC_API_KEY— Your API key (passed toapiKeyoption)ANTHROPIC_BASE_URL— Custom endpoint (defaults to Anthropic's; use for MiniMax, OpenRouter, etc.)ANTHROPIC_MODEL— Default model (falls back to hardcoded Sonnet; recommend setting this explicitly)
# Example: use MiniMax instead of Anthropic
export ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic
export ANTHROPIC_API_KEY=sk-...
export ANTHROPIC_MODEL=MiniMax-M3Quick start
import { createCodingAgent } from '@arasandev/harness/preset';
const agent = createCodingAgent({ apiKey: process.env.ANTHROPIC_API_KEY });
for await (const ev of agent.send('list the .ts files and count them')) {
if (ev.type === 'text_delta') process.stdout.write(ev.text);
}createCodingAgent wires coreTools + TodoWrite + WebFetch + a rules-based
permission gate + cost tracking + compaction + subagent delegation in one call.
All options are optional; apiKey falls back to ANTHROPIC_API_KEY.
Build a custom tool
import { tool } from '@arasandev/harness';
import { z } from 'zod';
const weatherTool = tool({
name: 'Weather',
description: 'Get current weather for a city. Returns temperature and conditions.',
inputSchema: z.object({ city: z.string().describe('City name, e.g. "London"') }),
async call({ city }, ctx) {
const res = await fetch(`https://wttr.in/${city}?format=j1`, { signal: ctx.signal });
return res.json();
},
isReadOnly: true,
isOpenWorld: true,
});Pass tools: [weatherTool, ...coreTools] to new Agent(...) or
extraTools: [weatherTool] to createCodingAgent(...).
Add permissions
import { rulesToCanUseTool, withPermissionCache } from '@arasandev/harness';
import type { PermissionRule } from '@arasandev/harness';
const rules: PermissionRule[] = [
// Always allow read-only inspection.
{ field: 'tool', matcher: { type: 'equals', value: 'Read' }, decision: 'allow' },
// Always allow git commands.
{ tool: 'Bash', field: 'input.command', matcher: { type: 'prefix', value: 'git ' }, decision: 'allow' },
// Hard-deny recursive root deletion.
{
tool: 'Bash',
field: 'input.command',
matcher: { type: 'regex', pattern: 'rm\\s+-rf?\\s+(/|~)' },
decision: 'deny',
reason: 'Refusing to delete a filesystem root.',
},
];
const canUseTool = withPermissionCache(rulesToCanUseTool(rules)).canUseTool;
// Pass to: new Agent({ canUseTool, ... })Rules evaluate first-match. Undecided calls are denied by default (fail-closed).
Compose with a host fallback via rulesToCanUseTool(rules, { fallback: askUser }).
Add hooks
import { Agent } from '@arasandev/harness';
import type { Hooks } from '@arasandev/harness';
const hooks: Hooks = {
preToolUse: [
async (ctx) => {
console.log(`→ ${ctx.tool.name}`, ctx.input);
return { kind: 'allow' }; // or { kind: 'deny', reason: '...' }
},
],
postToolUse: [
async (ctx) => {
console.log(`← ${ctx.tool.name} isError=${ctx.isError}`);
return {}; // or { modifiedOutput: redacted }
},
],
};
const agent = new Agent({ model: 'claude-opus-4-8', tools: [], hooks });
// Tip: import { MODELS } from '@arasandev/harness' and use MODELS.OPUS, MODELS.SONNET, MODELS.HAIKUA PreToolUse hook can rewrite or block the call before it runs. A
PostToolUse hook can rewrite the result the model sees. Both fire in
registration order.
Architecture
Developer
│
▼
Agent (per-turn owner)
owns: mutableMessages, usage, permissionMode, hookBus
API: send(prompt) → AsyncGenerator<AgentEvent>
interrupt() · registerTool() · getMessages() · getCostUsd()
│
▼
runLoop (per-model-call recursion)
while (true) { call model → execute tools → loop }
checks: abort signal, maxTurns, maxBudgetUsd, compaction
│
├─────────────────┬──────────────────┬────────────────────┐
▼ ▼ ▼ ▼
Transport Tool HookBus CanUseToolFn
anthropicTransport Tool<I,O> preToolUse permission gate
mockTransport tool() factory postToolUse rulesToCanUseTool
coreTools stop / session withPermissionCachecoreTools (7 built-in tools)
coreTools is the I/O tool set you pass directly to
new Agent(...). It does not require any factory wiring.
| Tool | What it does |
|------|-------------|
| Bash | Execute shell commands; supports run_in_background |
| Read | Read files (text, image, notebook, PDF) |
| Write | Create or overwrite files |
| Edit | String-based targeted file edits with mtime check |
| MultiEdit | Apply multiple edits to a file in one call |
| Glob | Recursive file pattern matching |
| Grep | Regex search across files with context lines |
createTodoWriteTool and createWebFetchTool are factories — they require
per-agent state wiring and are not in coreTools.
Design principles
1. Claude-Code-native naming. Type names, event names, and tool names match
real Claude Code so agents trained on Claude Code can use this SDK without
translation. PreToolUseHook, tool_result, PermissionRule are the same
concepts.
2. One obvious way. tool() is the only entry point for building tools.
rulesToCanUseTool is the only entry point for declarative policy. Helpers exist
but they compose rather than replace.
3. Self-describing contracts. Every tool's description field is its only
documentation from the model's perspective. ToolContext carries exactly what a
tool needs (cwd, env, signal, fileState) and nothing else.
4. Fail-closed. Undecided permission calls are denied. denyAll is the
zero-config default for canUseTool. Security boundaries do not require
explicit opt-in.
5. Small surface, additive. The required API is four fields on Tool and
one method on Agent. Optional fields are pure additions. Adding a field to
Tool is non-breaking; removing one is not.
With MCP tools (streamable HTTP or stdio)
import { Agent, coreTools, MODELS } from '@arasandev/harness';
import { connectMcpServers, createSamplingHandler } from '@arasandev/harness/mcp';
const agent = new Agent({ model: MODELS.SONNET, tools: coreTools, apiKey: process.env.ANTHROPIC_API_KEY! });
const registry = await connectMcpServers({
configs: [
{ name: 'my-server', transport: { kind: 'streamableHttp', url: 'https://my-mcp-server.example.com/mcp' } },
],
// Apply sampling handler to all servers with '*' wildcard:
clientOptions: { '*': { sampling: createSamplingHandler({ agent }) } },
});
await registry.bindToAgent(agent);
// agent now has coreTools (7) + all tools from my-server
for await (const event of agent.send('Use your tools')) {
if (event.type === 'text_delta') process.stdout.write(event.text);
}
await registry.disconnectAll();Note: loadMcpConfig, connectMcpServers, and createSamplingHandler are imported from
'@arasandev/harness/mcp' — not the front door.
Non-goals for 0.0.1
- Session tree / multi-root conversations. The
Agentowns a single linear message history. Branching (fork-and-rejoin, speculative execution) is out of scope. - Daemon bridge / long-lived process model. There is no persistent daemon, no IPC channel, no reconnect protocol. The agent is a library object in your process.
- 50-tool catalog. The SDK ships
coreTools(7 I/O tools) plus factories for TodoWrite, WebFetch, Bash with sandbox, plan-mode tools, and subagent delegation. A broad catalog of domain tools (browser, calendar, Slack, database connectors) is a host responsibility.
