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

@yesimagent/core

v0.0.9

Published

A small agent runtime on the [AI SDK](https://ai-sdk.dev). It runs turns — queueing an incoming message, reading history, calling the model, executing tools, and emitting events — while storage, state, and plugins stay pluggable.

Readme

@yesimagent/core

A small agent runtime on the AI SDK. It runs turns — queueing an incoming message, reading history, calling the model, executing tools, and emitting events — while storage, state, and plugins stay pluggable.

  • AI SDK underneath — the model loop is streamText with an AbortSignal; messages map onto ModelMessage. Re-exports ai, @ai-sdk/provider and @ai-sdk/provider-utils, so a host depending on @yesimagent/core sees one consistent AI SDK.
  • Append-only entries — everything that happens lands in storage as typed entries. History is what actually happened, and plugins can read and transform it before each step.
  • Turns are explicit — one active turn at a time, with defer / join / reject behavior for messages that arrive mid-turn.
  • Live output — every model part of every step is published on the "stream" channel as it arrives, so a UI can render a step before its messages are stored.

Install

pnpm add @yesimagent/core

Quickstart

import { createAgent, createUserMessage } from "@yesimagent/core";
import { createOpenAI } from "@ai-sdk/openai";

const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });

const agent = createAgent({
  model: openai.responses("gpt-5.1"),
  instructions: "You are a concise assistant.",
});

// Subscribe before sending: the turn starts on the next tick, and events are not replayed.
agent.channel.subscribe("agent", (event) => {
  if (event.type === "turn.done") console.log(`turn ${event.turnId} finished`);
});
// Same channel rules; model parts arrive while the step is still running (see Channel).
agent.channel.subscribe("stream", (part) => {
  if (part.type === "text-delta") process.stdout.write(part.text);
});

agent.send(createUserMessage("Say hello."));
await agent.wait();

Agent

createAgent(config) builds an agent. The config:

interface AgentConfig {
  id?: string; // defaults to a random UUID
  model: LanguageModel; // any AI SDK LanguageModel, e.g. from @yesimagent/gateway
  instructions?: string; // system prompt, joined with plugin instructions
  tools?: ToolSet; // see Tools
  runtimeContext?: Record<string, unknown>; // per-turn host state, see Tools
  toolsContext?: Record<string, unknown>; // per-tool context, see Tools
  toolChoice?: ToolChoice<ToolSet>; // per step; a prepareStep may override it
  activeTools?: readonly string[]; // only these tools are available per step; omit for all of them
  toolCallers?: ToolCallers; // which caller tools may invoke each tool
  settings?: LanguageModelCallOptions; // sampling and limits for every step
  storage?: AgentStorage<AgentEntry>; // defaults to in-memory
  plugins?: readonly AgentPlugin[]; // see Plugins
  maxSteps?: number; // per turn, defaults to 20
  initialState?: AgentState; // restored on init; every change persists as a state entry
}

Three ways to drive it:

  • send(message, options?) submits a turn and returns its id immediately. When a turn is already active, options.ifBusy decides: "defer" queues it (default), "join" attaches it to the active turn's next step, "reject" throws AgentBusyError. options.trigger: false records the message without starting a turn: an active "join" message is visible to the next step, while an active "defer" message is visible in the next turn.
  • run(message) submits a turn and returns an async iterable of that turn's events, closing after turn.done, turn.failed, or turn.aborted.
  • continue(prompt?) requests a turn and returns its id when it starts one right away. A prompt becomes that turn's opening user message, appended verbatim; without one the turn opens on what is already stored — no placeholder message is written either way, so the model sees the recorded input once. While a turn is active, or a wake is already pending, it merges into that single pending wake and returns undefined.

The pending wake is one slot, not a queue: it is remembered on its own, whether or not any prompt came with it, every prompt added while turns were running merges into that one message (joined with "; "), and the wake fires once the queue drains — including after a turn whose last step already ended it, so input that joined a terminal step still gets answered. A prompt reaches the model only through that message; turn.start events do not carry it. interrupt() and stop() discard a pending wake, and calling continue() while the agent is stopping throws Agent is stopping.

Lifecycle around turns:

| Method | Purpose | | -------------------- | ----------------------------------------------------------------------------------------------------------- | | init() | Runs plugin init hooks; lazy on the first turn. | | stop() | Interrupts the active turn, stops plugins in reverse order. | | wait(options?) | Resolves when the agent is idle; rejects if the optional signal aborts. | | continue(prompt?) | Requests a turn — prompt, when given, opens it as a user message — or merges into the pending wake. | | interrupt(reason?) | Aborts the active turn and everything queued. | | clear() | Clears storage. | | setModel(model) | Swaps the model; takes effect on the next step, where its messages record that step's provider/modelId. |

Messages and entries

Messages are what the model sees; entries are what storage keeps.

createUserMessage(content); // also: createSystemMessage, createAssistantMessage, createToolMessage
createCustomMessage(type, data); // runtime-only message, projected into model messages by plugins

Every message carries runtime metadata (id, timestamp); assistant messages additionally carry usage, finishReason, and the provider/modelId of the model that produced that step. A message becomes durable when the agent appends it as an entry:

interface AgentEntry<T> {
  type: "message" | "event" | "state"; // one entry per kind
  data: T;
  id: string;
  timestamp: number;
  turnId?: string; // set for messages produced inside a turn
}

Storage is append / read / clear. Built-in: createMemoryStorage() and createJsonlStorage(filePath). Any host backend — a database, a key-value store — is an AgentStorage implementation away. History is read in full on every step (transformEntries / transformMessages are the only trim points), so a store that outgrows a single file wants that backend.

Turns

A turn is one pass through the queue: turn.start → steps → turn.done / turn.failed / turn.aborted. Each step calls the model once, executes the tool calls it produced, and continues (up to maxSteps) while tool calls remain. Whether a step ends the turn is a step-level decision: after the joined messages are persisted, onStepFinish runs and a returned { continue: false } ends the turn even though tool calls were made. A step whose calls were all invalid keeps the turn going, so the model can repair its input.

An enforced toolChoice that the model ignores fails the turn with ToolChoiceViolationError.

Turn events:

| Event | Emitted when | | -------------- | ---------------------------------------------------------------------------- | | turn.queued | A message was deferred behind an active turn. | | turn.start | The turn begins. | | turn.step | A step finished, with that step's usage and finishReason. | | turn.done | The turn finished; carries the turn's totalUsage, summed across its steps. | | turn.failed | The model threw; error is { name, message, cause? }. | | turn.aborted | The turn was interrupted. |

Tools

Tools are AI SDK tools. The AI SDK runs them inside the step and core records the result messages:

import { tool } from "ai";
import { z } from "zod";

const tools = {
  check_phone: tool({
    description: "Look at the phone.",
    inputSchema: z.object({}),
    execute: (input, { abortSignal }) => checkPhone({ abortSignal }),
  }),
};

Every call gets a result. A returned value is sent as text when it is a string, as JSON otherwise, or through the tool's own toModelOutput when it declares one; a thrown error becomes an error-text result and the step continues, so the model can react. A tool with no execute records an error result and the step continues, so the model can react (within maxSteps).

Tool context

toolsContext hands server-side state to tools without putting it in the prompt. Each tool reads its own entry from execute's second argument:

const agent = createAgent({
  model,
  tools: { send_message: sendMessageTool },
  // keyed by tool name; `ctx` here is any host service, not a JSON value
  toolsContext: { send_message: { bot, channelOf: (id: string) => lookup(id) } },
});

// inside the tool
execute: (input, { context }) => context.bot.sendMessage(context.channelOf(input.channel), input.message);

When a tool declares a contextSchema, its entry is validated against it before execution; without one, the value passes through untouched — which is what non-JSON services need. A prepareStep may return a new toolsContext to change it for the rest of the turn.

A bare object built next to createAgent is checked against ToolSet, which types execute's arguments loosely. Annotate the tool (or build it with tool()) to keep the context and results typed:

import type { FunctionTool } from "@yesimagent/core";

const sendMessage: FunctionTool<SendInput, SendOutput, SendMessageContext> = {
  inputSchema: jsonSchema<SendInput>({/* … */}),
  toModelOutput: ({ output }) => ({ type: "text", value: output.summary }),
  execute: (input, { context }) => context.bot.sendMessage(input.channel, input.message),
};

Runtime context

runtimeContext is the turn's shared host state. It is handed to the model call as the AI SDK's runtimeContext, so lifecycle callbacks and telemetry see it there, and every plugin's prepareStep reads it from StepOptions. Tools never see it — publish what a tool needs through toolsContext:

const turn: AgentPlugin = {
  name: "turn-state",
  prepareStep(step) {
    const seen = (step.runtimeContext.seen as string[] | undefined) ?? [];
    return { ...step, runtimeContext: { seen: [...seen, step.turnId] } };
  },
};

A prepareStep that returns a different object changes it for the rest of the turn, exactly like toolsContext; the next turn starts again from config.runtimeContext. Treat the value as immutable and publish a new one instead of mutating in place.

Caller tools

A caller tool is a tool that calls other tools. The AI SDK's code mode is the built-in one: instead of calling each tool itself, the model writes a program, and that program reaches the host tools the SDK bound to it. toolCallers says who may call what (the table rides into the SDK's experimental_toolCallers, which validates it at runtime):

const agent = createAgent({
  model,
  tools: { code_mode: codeModeTool(), send: sendMessage, search: searchTool },
  toolCallers: { search: ["code_mode"], send: ["code_mode", DIRECT_TOOL_CALL] },
});

search is reachable from generated code and hidden from the model; send is reachable from both. A tool with no entry is untouched, so the table only names what it takes away.

A call from inside a program is a tool call like any other: it goes through the same wrapper, so it emits tool.start / tool.done and the beforeToolCall / afterToolCall hooks see it. Core keeps the caller's definition and the bound copy's execution wrapped on both sides, which is what makes that hold.

Ending the turn from a step

Turn termination is a step-level plugin decision, not a tool flag: onStepFinish runs once per step, after the joined messages are persisted, and receives { turnId, stepNumber, result } where result is that step's messages. Return { continue: false } when the step means the turn is over — typically a successful send — and return nothing so the model gets another step, typically after a failure it can fix:

const stopper: AgentPlugin = {
  name: "stop-after-send",
  onStepFinish(info) {
    for (const message of info.result.messages) {
      if (message.role !== "tool") continue;
      for (const part of message.content) {
        if (part.type !== "tool-result" || part.toolName !== "send") continue;
        const output = typeof part.output === "object" && part.output !== null && "value" in part.output ? part.output.value : undefined;
        if (typeof output === "object" && output !== null && "ok" in output && output.ok === true) return { continue: false };
      }
    }
    return undefined;
  },
};

The first plugin to return a decision owns it; the rest still observe the same step. A plugin that throws is treated as having no opinion.

Plugins can also intercept calls before they run: beforeToolCall may allow, block (with a reason), or replace the arguments, and afterToolCall can rewrite the result. Conflicting tool names across sources throw ToolConflictError at assembly.

Tool events: tool.start, tool.done, tool.failed, and tool.blocked.

Plugins

A plugin extends the agent through small, optional hooks:

import type { AgentPlugin } from "@yesimagent/core";

const memory: AgentPlugin = {
  name: "memory",
  enforce: "pre",                    // ordering: "pre" → registration order → "post"
  init(agent) { /* wire up */ },
  stop() { /* tear down */ },
  extendInstructions: () => "Recall relevant memories before answering.",
  extendTools: () => ({ memory_search: /* ... */ }),
};

The hook families:

| Hooks | Run | | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | init / stop | Agent lifecycle. init failures roll back already-initialized plugins. | | extendInstructions / extendTools | Prompt assembly; each contributes a paragraph / a tool set. Run once per turn, before its first step. | | onAppend / transformEntries / transformMessages | History pipeline, chained in plugin order; each hook's return feeds the next plugin. | | toModelMessages | Projects custom messages into model messages; first non-undefined wins. | | prepareStep | Rewrites the step (messages, toolsContext, toolChoice, activeTools, settings) before the model call. | | beforeToolCall / afterToolCall | Tool decisions and result transformation, including whether the result ends the turn. | | onTurnFinish | Observer after a turn completes; must not change the result. |

Assembly runs once per turn, before its first step, so a plugin whose instructions or tools change owns that change: compute them once, hand back the cache from extendInstructions / extendTools, and refresh the cache when your own trigger fires. The agent itself has no refresh entry point, and a tool name contributed twice at the same assembly is a ToolConflictError.

State

agent.state is a plain object plugins and instructions can read and write. AgentCustomState is the extension point — declare-merge your fields into it. State changes are serialized and appended to storage as state entries; on init, the latest one is restored, falling back to initialState.

declare module "@yesimagent/core" {
  interface AgentCustomState {
    userName?: string;
  }
}

agent.state.update({ userName: "Alice" }); // also: set(valueOrFn), get()

Channel

agent.channel is a tiny typed pub-sub (subscribe / emit) for anything that wants to observe or talk to the agent outside the turn loop — UIs, bridges, tests. The agent itself emits on two channels:

  • "agent" — lifecycle and turn events (AgentEvent, listed under Turns).
  • "stream" — every model part of every step, in order, as it arrives: the same TextStreamPart<ToolSet> stream streamText produces, SDK frames (start, start-step, finish-step, finish) included. Parts are not replayed, so subscribe before the turn starts; that is what lets a UI render a step while it is still running, before its messages are stored.
agent.channel.subscribe("stream", (part) => {
  if (part.type === "text-delta") process.stdout.write(part.text);
});

Declare your own channel by merging into AgentCustomEvent; listener errors are swallowed so they cannot affect the loop.

Errors

| Error | Meaning | | ------------------- | ------------------------------------------------------ | | AgentBusyError | send with ifBusy: "reject" while a turn is active. | | ToolConflictError | Two sources registered the same tool name. | | AgentRuntimeError | Base class for both. |

License

MIT