abstractflow-sdk
v1.2.2
Published
Abstractflow — zero-config JavaScript AI Agent SDK with tools, guardrails, multi-provider models, and multi-agent handoffs. Install, drop your key in .env, run.
Downloads
1,051
Maintainers
Readme
Abstractflow
JavaScript AI Agent SDK — builder API, Zod tools, multi-provider models, guardrails, multi-agent handoffs, and typed runtime events.
Abstractflow is a from-scratch Agent SDK core: one agent loop that speaks JSON steps (INITAL → THINK → TOOL_REQUEST → ANALYSE → OUTPUT), pluggable LLM providers (OpenAI / Claude / Gemini), typed tools, safety guardrails, human approval, and specialist handoffs with cycle/depth protection.
Table of Contents
- Why this SDK
- Architecture
- Installation
- 5-Minute Quick Start
- Core Concepts
- Step Pipeline (how the agent thinks)
- Project Layout
- Run & Test
- License
Why this SDK
Most tutorials jump straight to a big framework. This repo builds the runtime pieces yourself:
- Agent loop — forced JSON step pipeline so every turn is inspectable.
- Tools — Zod-validated inputs before
executeruns. - Providers — same
complete({ messages, stream, onToken })contract for OpenAI, Claude, Gemini. - Guardrails — allow / deny / modify / require_approval at four lifecycle phases.
- Handoffs — transfer ownership to a specialist agent with context + loop prevention.
- Events — listen to tool, handoff, guardrail, and run lifecycle signals.
Architecture
┌────────────────────────────────────────────────────────────────────┐
│ YOUR APP (import from abstractflow-sdk) │
│ defineTool · defineHandoff · guardrails · agent.on(...) │
└───────────────────────────────┬────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────┐
│ Agent Runtime (agent.js) │
│ Builder → instructions + tools + handoffs + guardrails │
│ run(query) → JSON step loop → TOOL_REQUEST / handoff / OUTPUT │
└───────┬───────────────────┬───────────────────┬────────────────────┘
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌────────────────┐ ┌────────────────────────────┐
│ Providers │ │ Tools │ │ Guardrails + Handoffs │
│ openai │ │ defineTool │ │ beforeRun / beforeToolCall │
│ claude │ │ Zod validate │ │ afterToolCall / afterRun │
│ gemini │ │ execute() │ │ defineHandoff + depth/cycle│
└───────────────┘ └────────────────┘ └────────────────────────────┘Installation
npm install abstractflow-sdkNode >= 20.12 required.
Abstractflow is an ESM-only package, so your package.json needs "type": "module":
{
"type": "module",
"dependencies": {
"abstractflow-sdk": "^1.2.2"
}
}Without it, node index.js fails with Cannot use import statement outside a
module on Node 20 (Node 22+ still runs it, but warns). npx abstractflow-sdk init
sets this for you.
Put a provider API key in a .env file at your project root:
OPENAI_API_KEY=sk-...
# optional, depending on provider:
# ANTHROPIC_API_KEY=...
# GEMINI_API_KEY=...That is the whole setup. Importing the SDK loads .env for you, so plain
node index.js / npm start works — no --env-file flag needed. The
nearest .env at or above the working directory is used, so running from a
subfolder still finds the project's keys. Real environment variables always win
over .env; set ABSTRACTFLOW_NO_DOTENV=1 to turn the auto-load off.
zod ships with the SDK and is re-exported, so one install covers everything:
import { Agent, defineTool, createOpenAIProvider, z } from 'abstractflow-sdk'Scaffold a runnable project
To skip the boilerplate entirely (ESM package.json, start script, starter
agent, .env):
npx abstractflow-sdk init my-agent
cd my-agent
npm install
# paste your key into .env
npm startinit never overwrites existing files — run it inside a project you already
have and it only fills in what is missing.
Local development (this repo)
git clone <your-repo-url>
cd agent-sdk
npm install
cp .env.example .env # add your keys
npm start # runs examples/demo.js
npm testPublish checklist (maintainers)
npm test
npm pack --dry-run # review files that will ship
npm login
npm publish --access public5-Minute Quick Start
Save this as index.js:
import {
Agent,
defineTool,
createOpenAIProvider,
z,
} from 'abstractflow-sdk'
const weatherTool = defineTool({
name: 'fetchWeatherInfo',
description: 'Fetches realtime weather by city name',
inputSchema: z.object({
cityName: z.string().min(1),
}),
async execute({ cityName }) {
// call your API here
return { cityName, weatherInfo: 'Sunny +30C' }
},
})
const agent = Agent.builder()
.name('assistant')
.setInstructions('You help users. Use fetchWeatherInfo for weather questions.')
.provider(createOpenAIProvider())
.tool(weatherTool)
.build()
const messages = await agent.run('What is the weather in Goa?')
console.log(messages[messages.length - 1])Put your key in .env, then run it:
node index.jsCore Concepts
1. Agent Runtime & Builder
Build agents with a fluent builder. Identity (name) matters for handoff traces and loop detection.
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import {
Agent,
defineTool,
createOpenAIProvider,
rejectInvalidInput,
z,
} from 'abstractflow-sdk'
const execAsync = promisify(exec)
const cliAccessTool = defineTool({
name: 'execCli',
description: 'Runs a CLI command on the machine and returns its output',
inputSchema: z.object({ cli: z.string().min(1) }),
async execute({ cli }) {
const { stdout, stderr } = await execAsync(cli, { timeout: 30_000 })
return { stdout, stderr }
},
})
const weatherAgent = Agent.builder()
.name('weather')
.setInstructions('You answer weather questions.')
.provider(createOpenAIProvider())
.build()
const agent = Agent.builder()
.name('coder')
.setInstructions('You are an expert coding agent.')
.provider(createOpenAIProvider({ model: 'gpt-4o-mini' }))
.tool([cliAccessTool])
.handoff([weatherAgent]) // or defineHandoff({ agent, ... })
.maxHandoffDepth(3) // default: 5
.guardrail([rejectInvalidInput({ schema: z.string().min(3) })])
.onApproval(async ({ reason, context }) => {
console.warn('Approve?', reason, context.toolName)
return true // or false to deny
})
.build()
const result = await agent.run('ls the current directory')
console.log(result[result.length - 1])
execCliruns whatever the model asks for. It is used throughout this README because it makes the guardrail examples concrete — pair it withblockDangerousToolCallsandrequireApprovalbefore pointing it at anything you care about.
| Method | Purpose |
| :--- | :--- |
| .name(string) | Agent identity (required for safe handoffs) |
| .setInstructions(string) | Your system prompt (merged with the harness) |
| .provider(p) | OpenAI / Claude / Gemini / custom |
| .tool(t \| t[]) | Register defineTool(...) tools |
| .handoff(h \| h[]) | Specialist agents or defineHandoff(...) |
| .maxHandoffDepth(n) | Cap nested handoffs (>= 1) |
| .maxLoop(n) | Cap pipeline turns per run (default 30) |
| .maxParseRetries(n) | Consecutive bad-format replies tolerated (default 3) |
| .guardrail(g \| g[]) | Safety checks at lifecycle phases |
| .onApproval(fn) | Human/process approval callback |
| .build() | Create the Agent instance |
When a model will not hold the format
A model that cannot produce the JSON step will not learn it on attempt 30, and
every retry resends a longer history. After maxParseRetries consecutive
unparseable replies the run stops with a StepFormatError naming the provider:
import { Agent, createOpenAIProvider, StepFormatError } from 'abstractflow-sdk'
const agent = Agent.builder()
.name('assistant')
.setInstructions('You are helpful.')
.provider(createOpenAIProvider())
.maxParseRetries(3)
.build()
try {
await agent.run('Explain BODMAS briefly')
} catch (err) {
if (err instanceof StepFormatError) {
console.error(err.message) // Provider "claude" (claude-sonnet-4) returned 3 …
console.error(err.raw) // the last reply, so you can see what it sent
} else {
throw err
}
}Measured on a doomed run with one tool registered — grinding to maxLoop
cost 30 calls / ~59k input tokens / ~90s; failing fast costs
3 calls / ~3.1k tokens / ~9s. The counter resets as soon as the model
produces one valid step, so a model that stumbles once is not punished.
2. Typed Tools (defineTool)
Every tool has a name, description, Zod inputSchema, and execute. Invalid input never reaches your function — you get { ok: false, code: 'VALIDATION_ERROR', ... }.
import { defineTool, z } from 'abstractflow-sdk'
const calcTax = defineTool({
name: 'calculate_tax',
description: 'Calculates 18% tax for an amount',
inputSchema: z.object({
amount: z.number().positive(),
}),
async execute({ amount }) {
return { tax: amount * 0.18, total: amount * 1.18 }
},
})What the LLM sends (one JSON step at a time):
{
"step": "TOOL_REQUEST",
"functionName": "calculate_tax",
"input": { "amount": 100 }
}For single-field schemas, a plain string is also coerced into that field.
Return shape from tool.execute:
| Result | Meaning |
| :--- | :--- |
| { ok: true, data } | Success — data goes back into message history |
| { ok: false, code: 'VALIDATION_ERROR', error } | Zod failed |
| { ok: false, code: 'EXECUTION_ERROR', error } | execute threw |
3. Model Providers
All providers implement the same contract:
{
name: string,
model?: string,
complete({ messages, stream, onToken }): Promise<string>
}Each factory reads its own environment variable and throws immediately if the key is missing — so build the one you actually have a key for, rather than constructing all three up front:
| Factory | Key it reads | Default model |
| :--- | :--- | :--- |
| createOpenAIProvider() | OPENAI_API_KEY | gpt-4o-mini |
| createClaudeProvider() | ANTHROPIC_API_KEY | claude-sonnet-4-20250514 |
| createGeminiProvider() | GEMINI_API_KEY / GOOGLE_API_KEY | gemini-2.0-flash |
import {
Agent,
createOpenAIProvider,
createClaudeProvider,
createGeminiProvider,
} from 'abstractflow-sdk'
function pickProvider() {
if (process.env.ANTHROPIC_API_KEY) {
return createClaudeProvider({ model: 'claude-sonnet-4-20250514' })
}
if (process.env.GEMINI_API_KEY) {
return createGeminiProvider({ model: 'gemini-2.0-flash' })
}
return createOpenAIProvider({ model: 'gpt-4o-mini' })
}
const agent = Agent.builder()
.name('bot')
.setInstructions('Be helpful.')
.provider(pickProvider()) // same agent code on any provider
.build()
console.log(agent.provider.name, agent.provider.model)Every factory also takes an explicit { apiKey, model } if you would rather not
rely on the environment: createClaudeProvider({ apiKey: myKey }).
Local & OpenAI-compatible servers
Anything that speaks the OpenAI Chat Completions API — Ollama, LM Studio, vLLM,
Groq, OpenRouter, Together — works through one factory. Guardrails, handoffs and
events behave identically on these models, because they all go through the same
complete(...) contract.
import { createOpenAICompatibleProvider } from 'abstractflow-sdk'
// Ollama, fully local, no API key
const ollama = createOpenAICompatibleProvider({
baseURL: 'http://localhost:11434/v1',
model: 'llama3.1',
name: 'ollama', // optional label shown in run_started / traces
})
// Groq
const groq = createOpenAICompatibleProvider({
baseURL: 'https://api.groq.com/openai/v1',
apiKey: process.env.GROQ_API_KEY,
model: 'llama-3.3-70b-versatile',
jsonMode: true, // only if the server supports response_format
})jsonMode defaults to false because many of these servers reject
response_format. You rarely need it: the agent loop already recovers the step
object from markdown fences or surrounding prose, so models without a JSON mode
(Claude and most local models) still drive the pipeline.
You can also hand-write a custom provider — any object exposing
complete({ messages, stream, onToken }) is accepted by .provider(...).
4. Guardrails
Guardrails run at four phases and return a decision:
| Phase | When | Typical use |
| :--- | :--- | :--- |
| beforeRun | Before the loop starts | Validate / block user input |
| beforeToolCall | Before a tool executes | Block dangerous commands, require approval |
| afterToolCall | After a tool returns | Redact secrets from tool output |
| afterRun | After the run finishes | Validate final OUTPUT step, redact history |
Decision actions: allow | deny | modify | require_approval
Built-in helpers
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import {
Agent,
defineTool,
createOpenAIProvider,
rejectInvalidInput,
rejectPromptLeakRequests,
blockDangerousToolCalls,
requireApproval,
redactSensitiveInfo,
validateStructuredOutput,
z,
} from 'abstractflow-sdk'
const execAsync = promisify(exec)
const cliAccessTool = defineTool({
name: 'execCli',
description: 'Runs a CLI command on the machine and returns its output',
inputSchema: z.object({ cli: z.string().min(1) }),
async execute({ cli }) {
const { stdout, stderr } = await execAsync(cli, { timeout: 30_000 })
return { stdout, stderr }
},
})
const agent = Agent.builder()
.name('coder')
.setInstructions('You are a careful coding agent.')
.provider(createOpenAIProvider())
.tool(cliAccessTool)
.guardrail([
// beforeRun — reject empty / too-short queries
rejectInvalidInput({ schema: z.string().min(3) }),
// beforeRun — block "show me your system prompt" / jailbreak attempts
rejectPromptLeakRequests(),
// beforeToolCall — deny destructive CLI patterns
blockDangerousToolCalls({
toolName: 'execCli',
patterns: [/rm\s+-rf/i, /sudo\b/i, /mkfs/i],
}),
// beforeToolCall — pause for human approval
requireApproval({
toolNames: ['execCli'],
reason: 'CLI execution requires approval',
}),
// afterToolCall — scrub secrets from tool results
redactSensitiveInfo({
patterns: [
{ match: /sk-[A-Za-z0-9_-]+/g, replace: '[REDACTED_API_KEY]' },
],
}),
// afterRun — also scrub final message history
redactSensitiveInfo({
name: 'redactSensitiveMessages',
phase: 'afterRun',
patterns: [
{ match: /sk-[A-Za-z0-9_-]+/g, replace: '[REDACTED_API_KEY]' },
],
}),
// afterRun — final assistant step must be OUTPUT
validateStructuredOutput({ requiredStep: 'OUTPUT' }),
])
.onApproval(async ({ reason, context }) => {
console.warn(`[approval] ${reason}`, context.toolName)
return true
})
.build()
const messages = await agent.run('list the files in this folder')
console.log(messages[messages.length - 1])Custom guardrail
import { defineGuardrail } from 'abstractflow-sdk'
const blockWeekends = defineGuardrail({
name: 'blockWeekends',
phase: 'beforeRun',
async run({ query }) {
const day = new Date().getDay()
if (day === 0 || day === 6) {
return { action: 'deny', reason: 'Agent disabled on weekends' }
}
return { action: 'allow' }
},
})On deny, the run throws GuardrailError (with phase + guardrail name) and emits run_failed / guardrail_triggered.
5. Human-in-the-Loop Approval
Sensitive tools can require an explicit approve callback via requireApproval + .onApproval(...).
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import {
Agent,
defineTool,
createOpenAIProvider,
requireApproval,
z,
} from 'abstractflow-sdk'
const execAsync = promisify(exec)
const cliAccessTool = defineTool({
name: 'execCli',
description: 'Runs a CLI command on the machine and returns its output',
inputSchema: z.object({ cli: z.string().min(1) }),
async execute({ cli }) {
const { stdout, stderr } = await execAsync(cli, { timeout: 30_000 })
return { stdout, stderr }
},
})
const agent = Agent.builder()
.name('admin')
.setInstructions('Use execCli carefully.')
.provider(createOpenAIProvider())
.tool(cliAccessTool)
.guardrail([
requireApproval({
toolNames: ['execCli'],
reason: 'CLI execution requires approval',
}),
])
.onApproval(async ({ reason, context }) => {
// Wire this to a UI, Slack message, CLI prompt, etc.
console.warn(`[approval] ${reason}`)
console.warn(`tool=${context.toolName} input=`, context.input)
return false // deny → tool does not run
})
.build()
const messages = await agent.run('list the files in this folder')
console.log(messages[messages.length - 1])If approval returns false (or no handler is set), the tool call is denied fail-closed.
6. Multi-Agent Handoffs
Transfer conversation ownership to a specialist. Handoffs are exposed to the LLM as normal tools (handoffToWeather, etc.).
import { Agent, defineHandoff, defineTool, createOpenAIProvider, z } from 'abstractflow-sdk'
const weatherTool = defineTool({
name: 'fetchWeatherInfo',
description: 'Fetches weather by city',
inputSchema: z.object({ cityName: z.string().min(1) }),
async execute({ cityName }) {
return { cityName, weatherInfo: 'Cloudy 22C' }
},
})
const weatherAgent = Agent.builder()
.name('weather')
.setInstructions('You answer weather questions using fetchWeatherInfo.')
.provider(createOpenAIProvider())
.tool(weatherTool)
.build()
const coder = Agent.builder()
.name('coder')
.setInstructions(
'You are a coding agent. For weather questions, hand off to the weather agent.',
)
.provider(createOpenAIProvider())
// .tool(yourTool) ← register this agent's own tools here
.handoff([
defineHandoff({
agent: weatherAgent,
description: 'Transfer weather questions to the weather specialist',
// optional: trim history before the specialist sees it
filterContext: (messages) => messages.filter((m) => m.role !== 'developer'),
// optional: hook before target runs
onHandoff: async ({ from, to, input }) => {
console.log(`handing off ${from} → ${to}`, input)
},
}),
])
.maxHandoffDepth(3)
.build()
const result = await coder.run('what is the weather in Saharanpur?')
console.log(result[result.length - 1])What happens under the hood:
- LLM emits
TOOL_REQUESTforhandoffToWeatherwith{ task, context? }. - SDK checks cycle (target already in chain) and max depth.
- Packs prior messages + a developer
HANDOFFnotice. - Emits
handoff_started→ runs target agent → emitshandoff_completed. - Target’s final messages become the source run’s result.
Safety:
| Rule | Behavior |
| :--- | :--- |
| Cycle | coder → weather → coder throws HandoffError |
| Max depth | Nested transfers beyond .maxHandoffDepth(n) throw HandoffError |
Shortcut: .handoff([weatherAgent]) auto-wraps with defineHandoff({ agent }).
7. Runtime Events & Tracing
Agent extends Node’s EventEmitter. Use AgentEvent constants for stable names.
import { Agent, AgentEvent, createOpenAIProvider } from 'abstractflow-sdk'
const agent = Agent.builder()
.name('assistant')
.setInstructions('You are helpful.')
.provider(createOpenAIProvider())
.build()
agent.on(AgentEvent.RUN_STARTED, (data) => {
console.log('run_started', data.provider, data.model, data.agent)
})
agent.on(AgentEvent.TOOL_STARTED, (data) => {
console.log('[tool_started]', data.toolName, data.input)
})
agent.on(AgentEvent.TOOL_COMPLETED, (data) => {
console.log('[tool_completed]', data.toolName, data.ok)
})
agent.on(AgentEvent.HANDOFF_STARTED, (data) => {
console.log(`[handoff] ${data.from} → ${data.to}`, data.input)
})
agent.on(AgentEvent.HANDOFF_COMPLETED, (data) => {
console.log(`[handoff done] ${data.from} → ${data.to} ok=${data.ok}`)
})
agent.on(AgentEvent.GUARDRAIL_TRIGGERED, (data) => {
console.log('[guardrail]', data.action, data.reason)
})
agent.on(AgentEvent.TEXT_STREAMED, ({ delta }) => {
process.stdout.write(delta)
})
// Every turn of the step pipeline, live — INITAL / THINK / ANALYSE included
agent.on(AgentEvent.STEP, (data) => {
if (!data.ok) return console.warn('[bad step]', data.error)
console.log(`[${data.index}] ${data.step}: ${data.text ?? data.functionName}`)
})
agent.on(AgentEvent.RUN_COMPLETED, () => console.log('[run_completed]'))
agent.on(AgentEvent.RUN_FAILED, (data) => console.error('[run_failed]', data.error))
await agent.run('What is the weather in Goa?')| Event | Fired when |
| :--- | :--- |
| run_started | run() begins |
| text_streamed | Streaming token delta |
| step | One pipeline turn parsed (ok:false if the model broke format) |
| tool_started | Tool / handoff tool about to run |
| tool_completed | Tool finished (ok true/false) |
| handoff_started | Transfer to specialist begins |
| handoff_completed | Transfer finished (ok) |
| guardrail_triggered | Guardrail deny / approval path |
| run_completed | Loop finished successfully (carries the parsed output) |
| run_failed | Error / guardrail / handoff failure |
step carries { index, agent, ok, step, text, functionName, input, raw }. It is
the whole reasoning trace as it happens — no hidden reasoning tokens — which is
what makes a run auditable while it runs, not only from the history run()
returns at the end.
Reading the final answer
run() returns the message history with every model reply untouched, which is
what you want for auditing but awkward to read from. The parsed final step is
available separately:
import { Agent, AgentEvent, createOpenAIProvider } from 'abstractflow-sdk'
const agent = Agent.builder()
.name('assistant')
.setInstructions('You are helpful.')
.provider(createOpenAIProvider())
.build()
// or read it from the event
agent.on(AgentEvent.RUN_COMPLETED, ({ output }) => console.log(output.text))
await agent.run('What is the weather in Goa?')
console.log(agent.lastOutput.text) // "Goa is sunny, 30C"lastOutput is reset at the start of every run and is undefined if the run
failed. After a handoff it holds the specialist's output.
8. Streaming
Pass { stream: true } to stream model tokens and listen on text_streamed.
import { Agent, AgentEvent, createOpenAIProvider } from 'abstractflow-sdk'
const agent = Agent.builder()
.name('assistant')
.setInstructions('You are helpful.')
.provider(createOpenAIProvider())
.build()
agent.on(AgentEvent.TEXT_STREAMED, ({ delta }) => process.stdout.write(delta))
const messages = await agent.run('Explain BODMAS briefly', { stream: true })
console.log('\n', messages[messages.length - 1])Token-by-token streaming is real on the OpenAI and OpenAI-compatible providers. The Claude and Gemini providers keep the same API but emit the response as a single
text_streamedevent once it arrives.
9. Message Interceptors
Observe every message pushed into history (user, assistant, developer, tool feedback):
import { Agent, createOpenAIProvider } from 'abstractflow-sdk'
const agent = Agent.builder()
.name('assistant')
.setInstructions('You are helpful.')
.provider(createOpenAIProvider())
.build()
agent.attachInterceptor((message) => {
console.log(`${message.role}: ${message.content}`)
})
await agent.run('What is the weather in Goa?')Useful for debugging the step pipeline without wiring every event.
Step Pipeline (how the agent thinks)
The harness forces the model to emit one JSON object per turn:
{
"step": "INITAL" | "THINK" | "TOOL_REQUEST" | "ANALYSE" | "OUTPUT",
"text": "...",
"functionName": "optional tool name",
"input": {}
}| Step | Meaning |
| :--- | :--- |
| INITAL | Understand the user intent |
| THINK | Plan / break down the problem |
| TOOL_REQUEST | Call a tool or handoff |
| ANALYSE | Verify intermediate results |
| OUTPUT | Final answer (ends the loop) |
Example weather flow:
INITAL— user wants weather for GoaTHINK— usefetchWeatherInfoTOOL_REQUEST—{ functionName: "fetchWeatherInfo", input: { cityName: "goa" } }- Tool result injected as developer feedback
OUTPUT— final natural-language answer
Project Layout
src/
index.js # Public package entry (export from 'abstractflow-sdk')
app/
agent.js # Agent + AgentBuilder + re-exports
tool.js # defineTool
guardrails.js # phases + built-in guardrails
handoff.js # defineHandoff, cycle/depth checks, context packing
events.js # AgentEvent constants
config.js # HARNESS_PROMPT (step pipeline)
env.js # .env auto-loader
providers/
base.js # provider contract helpers
openai.js
claude.js
gemini.js
index.js
bin/
abstractflow.js # `npx abstractflow-sdk init` scaffolder
examples/
demo.js # Local demo (npm start)Run & Test
# run the demo agent (reads .env)
npm start
# unit tests (tools, guardrails, handoffs, providers, events)
npm test
# see what npm will publish
npm run pack:checkLicense
ISC — see LICENSE.
