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

@adia-ai/agent

v0.8.50

Published

Composable chat-agent harness — assemble an agent from four declared parts (prompt layers, tools, workflows, resources) on top of @adia-ai/llm. Owns the tool-call loop, the session model, and the one shared event reducer. Works in browser and Node.

Readme

@adia-ai/agent

Composable chat-agent harness. Assemble an agent from four declared parts — prompt layers, tools, workflows, resources — on top of @adia-ai/llm. The package owns the tool-call loop, the serializable Session, and the one shared event reducer; it renders nothing (pair it with web-modules/chat or any UI).

The contracts are validated by a real consumer: the A2UI generation pipeline runs on them (gh#664) — its engine escalation ladder is a workflow (packages/gen-ui/a2ui/compose/strategies/cascade.js), its corpus reads are tool-mode resources, and its refiner is a defined tool. What that migration could not express became this package's hardening pass (gh#665): workflow verdicts, exit gates, same-step retry, a services channel, guardrails, tracing, session memory, and partial tool-input deltas.

Deferred, with reason: @genui/adia-producer's bridge keeps its own loop. The retry seam covers per-tier round budgets, but the bridge also owns a turn-wide maxProviderCallsPerTurn budget spanning steps, a genui TurnTrace, and OrchestrationExhaustedError semantics that come from @genui/producer — none of which this package models, and modelling them here would import another package's orchestration vocabulary into a general harness.

Install

npm install @adia-ai/agent

Usage

import {
  createAgent, promptLayer, defineTool, defineWorkflow,
  defineResource, reduce,
} from '@adia-ai/agent';

const agent = createAgent({
  llm: { model: 'claude-sonnet-4-6', proxyUrl: '/api/chat' },
  prompt: [
    promptLayer('identity', 'You are the AdiaUI copilot.', { cache: true }),
    promptLayer('context', () => new Date().toISOString()),   // per-turn, never cached
  ],
  tools: [
    defineTool({
      name: 'search_patients',
      description: 'Find a patient by name or MRN',
      inputSchema: { type: 'object', properties: { q: { type: 'string' } }, required: ['q'] },
      execute: async ({ q }) => lookup(q),                    // or endpoint: { url, method }
    }),
  ],
  workflows: [
    defineWorkflow('generate', [
      { name: 'zettel',     run: zettel,     until: r => r.strategy === 'composition-match' },
      { name: 'free-form',  run: freeForm,   until: async r => r.ok && await passesGate(r) },
      { name: 'monolithic', run: monolithic },                // terminal — always accepted
    ]),
  ],
  resources: [
    defineResource({ name: 'visit-summary', mode: 'attach', get: fetchSummary }), // app-controlled
    defineResource({                                                              // model-controlled
      name: 'care-plan', mode: 'tool',
      inputSchema: { type: 'object', properties: { section: { type: 'string' } } },
      get: ({ section }) => readPlan(section),   // non-string returns are JSON-serialized
    }),
  ],
  maxToolRounds: 8,
});

let session = agent.createSession();            // plain JSON-safe object — you own persistence
for await (const event of agent.send(session, 'weather in Oslo?')) {
  session = reduce(session, event);             // the one reducer — the single session writer
  render(event);                                // text | thinking | tool_use | tool_result | done | error
}
await agent.run(session, 'generate', { intent });

The four parts

| Part | Declared with | What the harness does | |---|---|---| | Prompt | promptLayer(name, text, {cache}) | Static layers first, cache_control on the last cached one (Anthropic block array; joined string elsewhere) | | Tools | defineTool({name, inputSchema, execute\|endpoint}) | JSON-schema check at the call boundary, then the execute-and-feed-back loop (bounded by maxToolRounds) | | Workflows | defineWorkflow(name, steps, {exitGate}) | Sequential cascade — until accepts, absent until is terminal, when skips, retry re-runs the same step | | Resources | defineResource({name, mode, inputSchema, get}) | attach → dynamic prompt layer each turn; tool → a read-only read_<name> tool taking inputSchema's arguments |

The attach envelope contains what it wraps. An attach-mode resource inlines somebody else's bytes into the system prompt, so a body containing </resource> would otherwise close its own envelope and everything after it would read as prompt — a file, a database row, or an MCP server could write instructions into the system prompt that way. attachLayers escapes the two structural sequences (<resource and </resource) in the body and the attribute specials in name / description. Only those: attached bodies here are catalog JSON, component markup and docs, and mangling every < in them to close one hole would degrade the data the model reads on every turn for a containment property those two sequences already give. Everything else arrives byte-for-byte. This applies to every attach resource, hand-declared or MCP.

A resource's get receives (input, ctx)ctx.signal is the turn's cancellation. Existing one-argument gets are unaffected.

Three cross-cutting layers ride on top: guardrails (veto/rewrite), tracing (structured records of what the harness did), and session memory (a JSON-safe slot on the Session). Tools and resources may also arrive from an MCP server instead of being hand-declared — see MCP-client mode.

Workflows in detail

until and when may both be async, and until also receives the context (until(result, ctx)) — the acceptance gate for a generated artifact usually has to read something. The result carries the whole run:

const { result, step, accepted, attempts } = await agent.run(session, 'generate', { intent });
// attempts: [{ step:'zettel', accepted:false, reason:'no-emission', round:1, result:… },
//            { step:'free-form', accepted:true, round:1, result:… }]

attempts lists every step round that RAN (a when-skipped step doesn't appear), in order, with its verdict — that list is the escalation trace a UI renders. accepted is false only when the last step also rejected; give the ladder a terminal step (no until) if you want a guaranteed answer.

Verdicts. until may return a verdict object — { accepted, reason, detail, data } — or any other value, read for its truthiness, which is what a pre-verdict predicate (r => r.messages.length) already did. Only an object with a boolean accepted is treated as a verdict. The reason lands on the attempt, so a consumer renders why the ladder climbed instead of recomputing it:

until: (r) => r.messages.length
  ? { accepted: true }
  : { accepted: false, reason: 'no-emission', detail: 'engine emitted nothing' },

Exit gate. The check every step's result must pass is declared once on the workflow instead of restated in every until:

defineWorkflow('generate', steps, {
  exitGate: async (result, ctx) => await validatesAgainstCatalog(result, ctx.input),
});

It runs after a step's own until accepted, and a rejection falls the ladder through exactly like an until rejection (the attempt carries gateRejected: true and reason: 'gate-rejected'). A terminal step — one with no until — is not exit-gated: the floor of a ladder is the answer of last resort. Give the last step until: () => true if you want it gated too.

Same-step retry. A step may retry against its own rejection before falling through:

{ name: 'repair', retry: { maxRounds: 3 },
  run: (ctx) => generate(ctx.input, ctx.feedback),   // ctx.round is 1-based
  until: (r) => r.valid ? { accepted: true } : { accepted: false, reason: 'invalid', data: r.errors } }

ctx.feedback is the previous round's verdict; each round is its own entry in attempts. maxRounds counts total runs (2 = one retry). A workflow exit-gate rejection consumes a round exactly like an until rejection, and reaches the next round as ctx.feedback carrying gateRejected: true — so a repair round can tell the gate's objection from the step's own.

Services. ctx.input is what the run is ABOUT; ctx.services is what it may USE. Declare them on the agent, per run, or both (per-run wins):

createAgent({ …, services: { llmAdapter, store } });
await agent.run(session, 'generate', { intent }, { services: { llmAdapter: turnAdapter } });

Services used to ride input, and still may — nothing breaks — but a new consumer should keep input the subject and put dependencies here.

Events

agent.send() yields the AgentEvent union; reduce(session, event) folds it. Every failure inside a tool call becomes an isError tool_result the model can react to — the loop throws for nothing tool-shaped.

{ type:'message', role:'user', text }
{ type:'text', text, snapshot } · { type:'thinking', text }
{ type:'tool_input_delta', id, name, partial, snapshot }
{ type:'tool_use', id, name, input } · { type:'tool_result', id, name, output, isError? }
{ type:'step', workflow, step, data? }
{ type:'progress', stage } · { type:'surface', surfaceId, line }
{ type:'guardrail', name, target, action, reason?, toolName?, toolUseId? }
{ type:'trace', record } · { type:'memory', patch }
{ type:'done', text, usage, stopReason } · { type:'error', error }

stopReason passes through raw from the provider; the loop adds exactly two synthetic values — max_tool_rounds when the bound is hit, and guardrail_denied when an output guardrail vetoed the turn.

thinking / step / error / progress / surface / tool_input_delta / guardrail / trace are render-only — reduce() folds them into no session change. memory is the one new event that DOES change the session.

Partial tool input

While a provider streams a tool call's arguments, each fragment surfaces as tool_input_delta (Anthropic input_json_delta, OpenAI partial argument strings; Gemini streams complete calls and emits none). snapshot is the raw JSON so far — incomplete and unparseable until the matching tool_use arrives. Render progress from it; execute only on tool_use.

Progress events (CHAT-HARNESS law 3)

progress carries a CLOSED, code-owned stage: one of PROGRESS_STAGES ('sent' | 'started' | 'reasoning' | 'content' | 'validating' | 'retry' | 'tool' | 'done'), exported alongside the isProgressStage() guard so any reader — a deserialized wire message, a stored transcript — can drop an out-of-vocabulary value instead of rendering it. Never render stage as label text directly; a UI's label table is code-owned and keyed on these strings, never on model output (the honesty guard).

The loop today emits only the stages it genuinely reaches — sent before every provider call, retry before a call that repeats a round after tool feedback, tool before each tool executes, done right before the terminal done event. started / reasoning / content / validating are declared in the vocabulary for future producers (the L1 loop doesn't independently observe those signals today, so it doesn't invent an emission point for them).

Surface events

{ type:'surface', surfaceId, line } carries one generative-UI wire line for an envelope-keyed surface. This package only carries the event kind on the union — turning it into a rendered surface is L3's job (web-modules/chat, WCH-4), never this package's.

Framed client-message turns (CHAT-HARNESS law 6)

A surface action, a function/tool result, or a validation rejection re-enters the agent as a turn — never a raw data blob — through one framing function:

import { frameClientMessage, shouldRunTurn } from '@adia-ai/agent';

const msg = { kind: 'surfaceAction', surfaceId: 'sf_1', action: 'submit', context: { formId: 'f1' } };
if (shouldRunTurn(msg)) {
  for await (const event of agent.sendClientMessage(session, msg)) { /* … */ }
}

ClientMessage arms and their framed wording:

| Kind | Framed turn | |---|---| | surfaceAction | The user triggered the ${action} action on surface ${surfaceId} with context ${JSON(context)}. | | functionResult | The function ${call} returned: ${JSON(value)}. | | validationRejection | The previous surface was rejected (${code}): ${message}. Emit a corrected surface. | | dataModelUpdate, ack | silent-apply — shouldRunTurn returns false; frameClientMessage throws if called on one directly |

agent.sendClientMessage(session, msg, opts?) is the wired entry: it gates with shouldRunTurn, frames with frameClientMessage, then runs the same loop as agent.send(). A silent-apply message yields nothing and never touches the provider — the caller already applied it directly (e.g. to a surface's data-model store).

MCP-client mode

Point the agent at an MCP server and its tools and resources join the declared ones. Nothing downstream knows the difference: the calls go through the guardrail chain, the tracer records the executions, checkInput runs at the boundary, a failure comes back as an isError tool_result. MCP is a source of tools, not a second kind of tool.

import { createAgent, mcpServer } from '@adia-ai/agent';

const agent = createAgent({
  llm: { model: 'claude-sonnet-4-6', proxyUrl: '/api/chat' },
  integrations: [
    mcpServer({
      url: 'https://mcp.example.com/mcp',
      headers: { authorization: `Bearer ${token}` },  // v1 auth: headers only
      prefix: 'wiki_',            // namespace its tool names
      tools: ['search', 'fetch'], // allowlist; absent = everything it lists
      maxTools: 24,               // loud cap, default MAX_INTEGRATIONS (16)
      attach: ['style-guide'],    // resources to inline every turn
      timeoutMs: 60_000,          // per request; 0 disables (default 60s)
    }),
  ],
});

const report = await agent.connect();   // optional — otherwise the first turn does it
// [{ url, serverInfo, protocolVersion, tools: [...], resources: [{name, uri, mode}] }]
await agent.close();                    // ends the MCP session(s)

Connection is lazy and memoized. createAgent is synchronous and a constructor that reaches the network is a constructor that can fail, so the handshake happens on the first turn — or eagerly on agent.connect(), which returns what each server offered. agent.tools lists the declared set until the sources connect, and everything afterwards. A failed connect is not cached as a verdict: the next turn tries again.

Failure is loud where it must be and model-visible where it can be.

| When | What happens | |---|---| | Server unreachable, non-2xx, unspeakable protocol version, tools/list fails | McpConnectionError thrown out of connect() / send(). An infrastructure fault the model cannot work around — the same class as a missing @adia-ai/llm, never a silent empty tool list | | Two sources (or a source and a declared tool) contribute the same tool name | McpConnectionError at connect — set a prefix | | More tools listed than maxTools | McpConnectionError naming the count. resolveIntegrations' cap silently drops the overflow; this one refuses to serve two thirds of a server without saying so | | A tools/call fails, times out, is aborted, or the server goes away mid-session | isError tool_result (McpCallError's message). The model sees it and the loop continues | | The server answers isError: true | Same — the error text reaches the model as an error, never as prose that reads like an answer | | An attach-mode resource's resources/read fails mid-session | McpCallError thrown out of send() during prompt assembly. There is no turn to degrade into: the prompt the model was going to see is the thing that could not be built |

Nothing waits forever. Every round trip carries timeoutMs (default DEFAULT_MCP_TIMEOUT_MS, 60 s), covering both the fetch and the wait for the answer to arrive on an SSE body — a server that accepts a POST and then says nothing is a real failure mode and is otherwise indistinguishable from a hang. The default has to clear the slowest legitimate answer, and for MCP that is a tool that thinks (the in-repo a2ui server runs an LLM inside tools/call), so it is generous rather than handshake-sized; raise it per source for a slower server, or set 0 if you own the clock through signal. A timeout during connect throws; a timeout during a call reaches the model like any other call failure.

Abort is plumbed end to end. agent.send(session, text, { signal }) puts that signal on the turn's ctx, and an MCP tools/call or resources/read carries it to the wire — a user who cancels mid-answer cancels the server call instead of leaving it running and ignored. Aborting the turn that happens to be doing the connect cancels the handshake too; the signal does not stay attached to the resulting connection, so a later turn is unaffected.

Failures leave nothing open. If a source fails after its handshake succeeded, its session is closed before the error propagates; if the second of two sources fails, the first one's session is closed too. close() during an in-flight connect wins — the late arrival closes what it opened and rejects with "the agent was closed while connecting" rather than quietly reinstalling itself. close() ends the sessions but does not spend the agent: a later send() reconnects.

Schemas pass through verbatim. A server's JSON Schema reaches the model unmodified, including keywords checkInput cannot enforce. The server is the authoritative validator (its rejection returns as an error result), so a faithful copy of a contract someone else owns beats a locally-enforceable subset that describes the tool less accurately. The manifest states this with the new IntegrationManifest.validation: 'remote' rather than leaving it implied; 'local' (the default, and every hand-declared integration) keeps the strict registration allowlist, because there the only validator is us.

Transport is streamable-HTTP, hand-rolled, no new dependency. @modelcontextprotocol/sdk brings a server framework, stdio/websocket transports, an auth stack and zod for a client subset that is six JSON-RPC methods over one POST endpoint; this package stays dependency-free apart from @adia-ai/llm. The SDK still belongs in packages/gen-ui/a2ui/mcp, which serves. stdio is not supported — it needs a child process, so it is Node-only by construction and no in-repo consumer wants it; a mcpServerStdio() Node entry point is the shape to add if one does.

In a browser, the server must be reachable by fetch — meaning it sends CORS headers for your origin, or you put it behind the same proxy you already route @adia-ai/llm through (proxyUrl) and point url at that path. This package deliberately ships no proxy: a server-side pass-through is the host app's, and forwarding mcp-session-id and mcp-protocol-version both ways is all it has to do. fetchImpl is the injection seam (tests use it for the in-process server; a host can use it to route through its own client). The in-repo packages/gen-ui/a2ui/mcp server sends no CORS headers today, so reaching it from a page means proxying it; from Node it works directly.

MCP resources, and the gh#671 ruling

A server's resources map onto the two existing modes and no new one:

  • tool (default) — the harness cannot know which of a remote server's resources belong in every prompt, and attaching all of them grows the system prompt without bound. So each becomes a read_<name> tool.
  • attach — the APP promotes specific resources by name or URI (attach: ['style-guide']), and those are fetched and inlined every turn.

That is exactly MCP's own application-controlled vs. model-controlled distinction, expressed with the modes the harness already has.

gh#671 asked whether engine-internal retrieval (zettel and free-form reading corpus content on their own schedule inside a workflow step's run) needs a third mode — 'internal'. Ruled no, and W3 did not need one either. A resource mode is a declaration of who invokes it and when, and both existing modes name a party the harness mediates: attach = the harness fetches, tool = the model asks. 'internal' would name "engine code, whenever it likes", which is not a mediation — it is a function call. Declaring it would put into the resource contract a thing the model can never call and the harness never fetches, buying nothing but a second way to spell await. The two things the issue actually wanted are already contracts: the dependency is declared through services (W2), and the invocation is visible through ctx.trace in the workflow phase. Server-pushed MCP resources do not need it either — every read is either app-scheduled or model-asked.

Everything a server returns is DATA. A tool description, a resource body, an error string — a description that reads like an instruction is still a description. It lands in the tool spec or inside a <resource> envelope and decides nothing about what the harness does. Guardrails are how you constrain what a remote tool may actually do.

Guardrails

The deterministic layer between the model and the world — code that runs outside the model's context and cannot be talked past. Declared once at createAgent, two attach points, both optional:

import { defineGuardrail } from '@adia-ai/agent';

createAgent({
  llm: { … },
  guardrails: [
    defineGuardrail({
      name: 'scope-to-tenant',
      // Before the call executes: allow (or return nothing), rewrite, deny.
      toolCall: (call, ctx) => ({ action: 'rewrite', input: { ...call.input, tenantId } }),
    }),
    defineGuardrail({
      name: 'redact',
      // On the model's completed text for the round.
      output: (text) => ({ action: 'rewrite', text: text.replace(SSN, '[redacted]') }),
    }),
  ],
});

Guardrails run in declaration order and chain: a rewrite feeds the next one, the first deny short-circuits.

  • Tool-call deny → the model gets an isError tool_result naming the guardrail and its reason, and the loop continues. Nothing executes.
  • Output deny → the turn ends with no assistant text and stopReason: 'guardrail_denied'; the reason rides the guardrail event.
  • Output rewrite → the rewritten text is what done carries and what the transcript keeps. A final { text: '', snapshot: <rewritten> } event follows, and a denial sends { text: '', snapshot: '' }. A UI that folds text events by APPENDING the delta must treat an empty delta as a replace from snapshot, or it will keep showing the original while the transcript says otherwiseweb-modules/chat's wireAgentEvents does exactly this via chatShell.setStreamedText().

Two scope limits worth knowing before you rely on the output chain:

  • Output guardrails see the text of the round that ENDS the turn. Text from a round that also called tools is intermediate reasoning the model supersedes, and denying it has no coherent answer for the tool calls emitted beside it — so it passes through unguarded and reaches the transcript. Put the check on toolCall if what matters is what the model is about to DO.
  • A guardrail may ctx.remember(patch) — it receives the same context a tool does. The write is drained as a memory event before done, never dropped.

onToolCall is now exactly one guardrail of the toolCall kind: it still works, still denies with its original wording, and runs ahead of the declared chain.

A guardrail decides from the call and the harness's own context. Text produced by a model, a tool, or a resource is DATA — a guardrail that lets such text talk it into allowing something is not a guardrail.

Tracing

Off by default. trace: true uses the console sink; a function IS the sink. Every record is also a trace event on the stream:

const agent = createAgent({ llm: { … }, trace: (record) => myLogger.debug(record) });
// { phase, name, at, sessionId?, data? }

phase is the closed vocabulary (TRACE_PHASES, guarded by isTracePhase()): prompt (assembly: layer count, cached blocks, chars), request / response (per provider call: round, model, tool count, stop reason, ms), tool (per execution: name, id, ms, isError), workflow (step:start / step:verdict with the verdict reason), guardrail (each non-allow decision), usage (the turn's token totals), mcp (connect / connect:failed per source — a tool a server contributed then executes under tool like any other). A sink that throws warns and the turn continues. There is no exporter protocol and no dependency — an OpenTelemetry bridge is a caller-side function.

Session memory

Per-session, JSON-safe, and written ONLY through reduce() — the same single-writer rule the messages obey:

import { remember } from '@adia-ai/agent';

session = reduce(session, remember({ patientId: 'p_7', tone: 'brief' }));
session.memory;                                  // { patientId: 'p_7', tone: 'brief' }
session = reduce(session, remember({ tone: null }));   // null deletes the key

A tool writes by ASKING: ctx.remember(patch) queues the patch, and the loop yields it as a memory event right after that tool's result — and drains anything still queued before done, so a final-round tool's write (or a guardrail's, which shares the context) is never dropped. The asker never touches state.

A patch that would not survive JSON.stringifyJSON.parse unchanged (a Date, a function, undefined, a class instance) throws at the write, naming the path. Memory rides the Session through toJSON/fromJSON; where that JSON is stored stays the caller's — cross-session persistence is a seam this package deliberately does not implement.

Byte-identity baseline (CHAT-HARNESS law 2)

Every axis on AgentConfig is optional, and absence must produce a byte-identical request to before the axis existed. baseline/prompt- equivalence.baseline.json pins the exact ChatOpts a minimal config (one static prompt layer, nothing else) produces; baseline.test.js re-derives it every run and diffs byte-for-byte, including asserting no optional-axis key (tools, cache, signal, …) appears at all — not even as an empty array.

Changing a default that legitimately changes this output breaks the test until the baseline is deliberately regenerated:

npm run build -w @adia-ai/agent
node scripts/build/regen-agent-baseline.mjs

Never hand-edit the JSON file — regenerate it, then diff-review the change like any other pinned contract.

Testing

scriptClient(turns) is a deterministic LLMClient: script [{text, toolUse}] turns and drive the whole loop keylessly. It also records every ChatOpts it was called with (client.calls) so tests can assert what reached the wire.

mcp-fixture.mjs's createTestMcpServer({ tools, resources, … }) is the same idea for MCP: an in-process server behind a fetch-shaped function (fetchImpl), speaking real JSON-RPC with session headers, cursor pagination, isError results, and either a JSON or an SSE response body — no network, no ports, no SDK.