npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ageflow/runner-api

v0.4.1

Published

OpenAI-compatible HTTP runner for ageflow (OpenAI, Groq, Together, Ollama, vLLM, LM Studio, Azure).

Readme

@ageflow/runner-api

npm

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-api

Quick 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 invocation

Session 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

runner.shutdown() is process-scoped — it is called automatically by the AgentFlow CLI (agentwf run) and the server's close() method at process exit. You do not need to call it manually when using those entry points.

If you are using ApiRunner directly (outside the CLI or server), call shutdownAllRunners() from @ageflow/core when your process exits:

import { shutdownAllRunners } from "@ageflow/core";

process.on("SIGTERM", async () => {
  await shutdownAllRunners();
  process.exit(0);
});

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