Package Exports
- @ageflow/runner-api
Readme
@ageflow/runner-api
OpenAI-compatible HTTP runner for ageflow. Talks to any
/chat/completions endpoint via fetch(). Supports multi-round tool calling
internally, pluggable session storage, and returns ToolCallRecord[] for
observability. Zero external dependencies.
Install
bun add @ageflow/runner-apiQuick start
import { registerRunner } from "@ageflow/core";
import { ApiRunner } from "@ageflow/runner-api";
registerRunner(
"api",
new ApiRunner({
baseUrl: "https://api.openai.com/v1",
apiKey: process.env.OPENAI_API_KEY!,
defaultModel: "gpt-4o-mini",
}),
);Then use runner: "api" in any defineAgent call:
import { defineAgent } from "@ageflow/core";
import { z } from "zod";
const summarize = defineAgent({
runner: "api",
model: "gpt-4o-mini",
input: z.object({ text: z.string() }),
output: z.object({ summary: z.string() }),
prompt: (i) =>
`Summarize in one sentence as JSON {"summary": string}:\n\n${i.text}`,
});Provider compatibility
| Provider | baseUrl |
|---|---|
| OpenAI | https://api.openai.com/v1 |
| Groq | https://api.groq.com/openai/v1 |
| Together AI | https://api.together.xyz/v1 |
| Ollama | http://localhost:11434/v1 |
| vLLM | http://localhost:8000/v1 |
| LM Studio | http://localhost:1234/v1 |
| Azure OpenAI | https://<resource>.openai.azure.com/openai/deployments/<model> |
For Azure you must include ?api-version=... directly in baseUrl — the runner
appends /chat/completions to baseUrl as a path segment and does not merge
query parameters separately. Do not pass api-version via headers; Azure
rejects requests where it appears only as a header.
Example: baseUrl: "https://<resource>.openai.azure.com/openai/deployments/<model>?api-version=2024-02-01"
Configuration
new ApiRunner({
// Required
baseUrl: "https://api.openai.com/v1", // trailing slash is stripped automatically
apiKey: "sk-...",
// Optional
defaultModel: "gpt-4o-mini", // used when spawn() args.model is not set
tools: { // tool registry — see Tool calling below
readFile: { description: "...", parameters: { ... }, execute: async (args) => ... },
},
sessionStore: myStore, // custom SessionStore — see Session persistence below
maxToolRounds: 10, // max tool-call loops before MaxToolRoundsError (default 10)
requestTimeout: 120_000, // ms before AbortController fires (default 120 000)
headers: { // extra headers forwarded on every request
"x-custom-header": "value", // e.g. custom tracing headers
},
fetch: myFetchImpl, // injectable fetch (default: globalThis.fetch)
})Tool calling
Register tools that the model may invoke. The runner loops internally until
the model stops requesting tool calls or maxToolRounds is reached.
import { ApiRunner } from "@ageflow/runner-api";
import * as fs from "node:fs/promises";
const runner = new ApiRunner({
baseUrl: "https://api.openai.com/v1",
apiKey: process.env.OPENAI_API_KEY!,
tools: {
readFile: {
description: "Read the contents of a file from disk",
parameters: {
type: "object",
properties: { path: { type: "string", description: "Absolute file path" } },
required: ["path"],
},
execute: async ({ path }) => {
return await fs.readFile(String(path), "utf-8");
},
},
writeFile: {
description: "Write content to a file",
parameters: {
type: "object",
properties: {
path: { type: "string" },
content: { type: "string" },
},
required: ["path", "content"],
},
execute: async ({ path, content }) => {
await fs.writeFile(String(path), String(content), "utf-8");
return "ok";
},
},
},
});
const result = await runner.spawn({
prompt: "Read ./README.md and summarize it in one sentence.",
tools: ["readFile"], // subset of registered tools exposed to model
});
console.log(result.stdout); // final model reply
console.log(result.toolCalls); // ToolCallRecord[] — every tool invocationSession persistence
By default each spawn() call gets a fresh UUID session handle and messages
are stored in an InMemorySessionStore (lives for the lifetime of the
ApiRunner instance). Pass a sessionHandle to resume a conversation:
const first = await runner.spawn({ prompt: "My name is Alice." });
// first.sessionHandle === "some-uuid"
const second = await runner.spawn({
prompt: "What is my name?",
sessionHandle: first.sessionHandle,
});
// second.stdout === "Your name is Alice."Custom SessionStore (e.g. Redis)
import type { SessionStore } from "@ageflow/runner-api";
import type { ChatMessage } from "@ageflow/runner-api";
import { createClient } from "redis";
const redis = createClient();
await redis.connect();
const redisStore: SessionStore = {
async get(handle) {
const raw = await redis.get(`session:${handle}`);
return raw ? (JSON.parse(raw) as ChatMessage[]) : undefined;
},
async set(handle, messages) {
await redis.set(`session:${handle}`, JSON.stringify(messages), { EX: 3600 });
},
};
const runner = new ApiRunner({
baseUrl: "https://api.openai.com/v1",
apiKey: process.env.OPENAI_API_KEY!,
sessionStore: redisStore,
});Observability
RunnerSpawnResult.toolCalls is a ToolCallRecord[] containing every tool
invocation made during the session:
const result = await runner.spawn({ prompt: "...", tools: ["readFile"] });
for (const call of result.toolCalls ?? []) {
console.log(call.name); // "readFile"
console.log(call.args); // { path: "./foo.ts" }
console.log(call.result); // "export const ..."
console.log(call.durationMs); // 12
}The executor passes toolCalls through to TaskMetrics / ExecutionTrace
when present, enabling end-to-end observability without extra instrumentation.
Validation
runner.validate() hits GET /models and returns { ok, version?, error? }.
Useful for health-checks and pre-flight guards:
const { ok, version, error } = await runner.validate();
if (!ok) throw new Error(`API runner not reachable: ${error}`);
console.log("First available model:", version);Error types
| Error class | When thrown |
|---|---|
MaxToolRoundsError |
Tool-call loop exceeded maxToolRounds |
ApiRequestError |
HTTP response was non-2xx |
ToolNotFoundError |
Reserved — executor pre-flight; runner itself soft-errors unknown tools |
import { MaxToolRoundsError, ApiRequestError } from "@ageflow/runner-api";
try {
await runner.spawn({ prompt: "loop forever", tools: ["infiniteTool"] });
} catch (err) {
if (err instanceof MaxToolRoundsError) {
console.error("Too many tool rounds:", err.message);
}
}Using MCP servers
Pass MCP server configuration via mcp.servers on any defineAgent call. The
API runner spawns each server as a stdio subprocess via
@modelcontextprotocol/sdk. Tools are discovered at spawn time and registered
in the tool-loop under the fully-qualified name mcp__<server>__<tool>.
import { defineAgent, safePath } from "@ageflow/core";
import { z } from "zod";
const fileAgent = defineAgent({
runner: "api",
model: "gpt-4o-mini",
input: z.object({ query: z.string() }),
output: z.object({ result: z.string() }),
prompt: ({ query }) => query,
mcp: {
servers: [
{
name: "filesystem",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
// Allowlist — only these tools are exposed to the model
tools: ["read_file", "list_directory"],
// Refine — validate path args before forwarding to the server
refine: {
read_file: z.object({ path: safePath({ allowAbsolute: false }) }),
},
// ${env:VAR} is resolved at launch time by the executor
env: { NODE_ENV: "${env:NODE_ENV}" },
// Keep this server alive across spawn() calls on the same runner instance
reusePerRunner: true,
},
],
},
});Allowlist (tools): when set, only the listed tools are added to the
tool-loop registry. Unlisted tools never reach the model, and a post-dispatch
guard rejects unexpected call attempts.
Refine (refine): a map of tool name → Zod schema. Arguments are validated
against the schema before the call is dispatched. Use safePath() to prevent
path traversal.
Environment expansion (env): values of the form ${env:VAR} are replaced
with the corresponding process environment variable at launch time.
reusePerRunner — server lifecycle pooling
By default each spawn() call starts its own MCP server subprocesses and stops
them when the call completes. Set reusePerRunner: true on a server to keep it
alive in a per-runner pool and reuse it across all spawn() calls on the same
ApiRunner instance. This avoids repeated cold-start overhead for servers that
are expensive to initialize.
// Server stays up across calls — warm on every spawn()
{ name: "filesystem", command: "npx", args: [...], reusePerRunner: true }runner.shutdown() — draining the pool
Call runner.shutdown() after all tasks are complete to gracefully stop pooled
servers. runner.shutdown() must be called manually by the caller — the
workflow executor does not call it automatically yet (auto-wiring is tracked in
issue #75). Forgetting to call it will leak MCP server subprocesses.
const runner = new ApiRunner({ baseUrl: "...", apiKey: "..." });
try {
await runner.spawn({ ... });
await runner.spawn({ ... });
} finally {
await runner.shutdown(); // stops all reusePerRunner servers
}API reference
new ApiRunner(config: ApiRunnerConfig)
Creates a new runner instance. All config fields except baseUrl and apiKey
are optional.
runner.validate(): Promise<{ ok: boolean; version?: string; error?: string }>
Checks connectivity by calling GET /models. Returns ok: false on any
error (network, 4xx, 5xx) — never throws.
runner.spawn(args: RunnerSpawnArgs): Promise<RunnerSpawnResult>
Executes a prompt, optionally resuming a session, and loops until the model
produces a non-tool-call response. Returns stdout (final text), sessionHandle,
tokensIn, tokensOut, and toolCalls.
runner.shutdown(): Promise<void>
Stops all pooled MCP server subprocesses (reusePerRunner: true) and clears
the pool. Per-spawn servers are already stopped by spawn() itself — only the
pool requires an explicit shutdown() call. Safe to call more than once.
License
MIT