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

ai-ax

v0.8.0

Published

AI Agent Experience (AI/AX) library for Hono + Cloudflare. One act surface for both human UI/UX and LLM MCP tools, with authentication, authorization, realtime sync and Claude API relay.

Readme

ai-ax

AI Agent Experience (AI/AX) library for Hono + Cloudflare.

ai-ax builds the human experience (UI/UX) and the AI agent experience (AI/AX) of a web app as one surface. An operation is declared exactly once, and that one declaration is a click handler for the person watching the screen and an MCP tool for the LLM at the same time, backed by one realtime state that both of them read and write.

Three ideas carry the library — one action, one tree, one road — and each of them is a mechanism you can point at, not a slogan: one action is a single tool registry that serves the UI, the MCP server and the cross-side transport; one tree is a single yjs map that is rendered as the screen, dumped as the agent's observation and encoded as the persistence snapshot; one road is a single ai object that every runtime adapter receives.

Why

The AI agent has become one more user of the web site. The web, however, was built on the assumption that a human is the one operating it, so when an agent path is bolted onto an app as a second route beside the browser, everything doubles: two operation surfaces, two auth stacks, two sets of bugs, roughly twice the code to manage. The two copies inevitably drift apart, and the drift is usually discovered weeks later in a log.

ai-ax removes the second copy instead of maintaining it. The person and the agent read the same state and call the same operations, so the declaration of an operation is its MCP tool, and the implementation lives in exactly one place. Every operation is synchronized in realtime through partyserver, whether it arrived from a browser click or from an MCP request, so the agent's moves appear on the human's screen as they happen.

Authentication is the one place where the two users genuinely differ, because an MCP request from the Claude API cannot carry the browser's session. ai-ax closes that gap with a signed token: when the user sends a prompt, the Hono side mints a token and hands it to the Claude API, the token comes back on every MCP request, and the MCP server verifies the signature before executing anything.

What

The assumed stack is Hono on Cloudflare Workers. Realtime sync is partyserver — the successor of partykit, which was deprecated after becoming a Cloudflare product — running yjs docs on Durable Objects deployed straight to Cloudflare. Authentication is Auth.js, and the LLM is the Claude API with its MCP connector. Hono itself is confined to the ai-ax/hono entry, so every other entry stays framework free.

The whole library is one loop: the browser and the agent join the same room, and every operation — clicked or tool-called — lands on the same doc and syncs back to both.

diagram

One action

x(params, config) builds the ai object. The first argument declares typed state keys with their initial values (write x<Keys>() when a key should start undefined), and the second argument is the service config.

// actions.ts
import { x } from 'ai-ax'
import { z } from 'zod'

export default x(
        { count: 0 },
        { name: 'counter', tree: 'count is the current value. Observe before acting', own: ({ sub }) => sub },
)
        .describe('A counting app.', (a) =>
                a
                        .action('increment', 'Add 1 to the current value', () => ++ai.count)
                        .action('overwrite', 'Overwrite the current value', { count: z.number() }, ({ count }) => (ai.count = count)),
        )
        .client('notify', 'Show an alert in the browser', { text: z.string() }, ({ text }) => alert(text))
        .server('heavy', 'Run heavy work on the server', { src: z.string() }, () => import('./heavy'))

Each definition takes the tool name first, and after that the description, the zod schema and the implementation in any order — each part is recognized by its type, so an inline handler reads hono-like as (name, schema, fn) while a dynamic import reads (name, () => import('./x'), description, schema) with the schema at the end where the whole list scans best. The implementation is either an inline function or a dynamic import whose default export receives (input, ai), so heavy or browser-incompatible code stays out of the other side's bundle. .action runs on whichever side called it, .client only in the browser, .server only in the Durable Object; when the other side calls it, the request travels over the shared doc and the result travels back, so a tool call from the LLM can open a dialog in the connected browser and return what the user did. .describe(text, fn) blocks nest, and their texts concatenate hierarchically into the MCP tool description. get_tree is built in — every service exposes the same observation tool, and config.tree replaces its description.

The declared names and schemas flow into the type of ai: ai.overwrite({ count: 3 }) typechecks, ai.overwrite(true) and ai.missing() do not.

One tree

All shared state lives in a single yjs map. State declared in the first argument is read and written through plain properties — ai.count = 3 above syncs to every connected browser and to the agent's next get_tree — and any undeclared key travels the same proxy: ai['files/a.txt'] = entry, ai['files/a.txt'] and delete ai['files/a.txt'] are how repeated rows live under a name/id convention. ai.rows(prefix) lists the direct children of a prefix sorted by pos, ai.scan(prefix) lists every descendant, ai.wipe(prefix) clears them, ai.tx(fn, origin?) groups writes into one transaction, and ai.history(options?) is the undo manager over the same map. ai.data() exposes the underlying Y.Map only for what a property can never express — observe subscriptions and whole-tree iteration.

The tree is also the persistence unit. config.load restores a snapshot when the doc first wakes, config.save persists one on the update debounce, on a durable alarm and on every disconnect — which is what survives Durable Object hibernation — and ai.snapshot() encodes it. config.ui names the volatile keys (a trailing / marks a prefix): they still sync and still show up in get_tree, but they are excluded from both the snapshot and the undo capture, so a saved file holds the work and not the open menus. config.history merges extra Y.UndoManager options such as captureTimeout. A lobby room skips persistence entirely, so an anonymous top page stays playable without touching storage. State that should stay out of the shared tree entirely deliberately steps off the road: the ai-ax/react entry ships a small atom store (atom, setAtom, useAtomValue) for browser-local values like an IME buffer or a chat stream.

x(
        {},
        {
                name: 'notes',
                lobby: 'notes',
                own: async ({ env, sub, path }) => (await env.my_bucket.head(`${sub}/${path}`))?.customMetadata?.site ?? '',
                load: async ({ env, sub, path }) => (await env.my_bucket.get(`${sub}/${path}`))?.arrayBuffer(),
                save: ({ env, sub, path, room }, buf) => env.my_bucket.put(`${sub}/${path}`, buf, { customMetadata: { site: room } }),
        },
)

One road

The app touches the library through the one ai object, and each runtime has exactly one adapter that receives it.

// index.ts (worker + Durable Object)
import { Hono } from 'hono'
import { hono } from 'ai-ax/hono'
import { Server } from 'ai-ax/server'
import { PROMPT } from './prompt'
import ai from './actions'

export default new Hono().use('*', hono(ai, { system: PROMPT }))
export class PartyServer extends Server {
        ai = ai
}
// client.tsx (browser)
import { useClient } from 'ai-ax/react'
import ai from './actions'

const App = () => {
        const { count } = useClient(ai)
        return <button onClick={() => ai.increment()}>{count}</button>
}

hono(ai, extra?) routes /parties/* to websocket sync, the MCP endpoint, the LLM relay and the Auth.js session, and lets everything else fall through to the app's own routes; extra merges server-only config such as the system prompt or an Auth.js adapter, so they never enter the browser bundle. Server is the Durable Object: the app subclass assigns it the one ai (class PartyServer extends Server { ai = ai }), and it records the owning user across hibernation, serializes action execution, and drives the load / save hooks. useClient(ai) connects once and re-renders on every tree change (Client is the component form, client(ai) the non-React form; route.synced fires after the first sync, and route.room: '' opens the local doc without any connection), and hydrate(ai, entries) fills that local doc from a published JSON snapshot without waking a Durable Object at all.

Runtime-only values — Cloudflare bindings, the authorized user, the room — travel the same road instead of module globals. .abstract(name) declares a function that actions may call before anyone has implemented it, and .override(name, (input, next, ai) => ...) supplies or wraps implementations onion-style, where next(input) runs whatever was underneath. The Durable Object overrides one well-known abstract before every execution: ai.peer() returns the authorized { env, room, sub, path }, and everything else an app needs (an R2 client, a sandbox runner) is an abstract the app declares in its actions and overrides in its PartyServer. Overrides intercept tools too, which is how a browser rewrites the variables the Claude API sends before an action runs.

createStream() from ai-ax/client is the browser side of the relay's wire format: POST to the relay streams plain text in which a [tool] line marks each MCP call and a trailing [usage] line closes the turn, and parse / send / prior-messages replay are the pieces an app needs to render and continue conversations without touching the format itself.

One resolver

Authorization concentrates into the single own function: given { env, sub, path } it answers which room this user may enter, and an empty string denies. Only two kinds of credentials exist, both jwt signed with AUTH_SECRET — a plain user id never crosses the network.

| path | credential | issued by | verified by | | ----------------------- | ---------------------------- | -------------------------------------------- | ------------------------------------------- | | Browser → Worker | session cookie (Auth.js jwt) | /api/auth (Google OAuth by default) | the Auth.js session | | Claude → Worker MCP | grant jwt { sub, path } | the LLM relay, minted per prompt | signature check before every tool execution | | Worker → Durable Object | room name + user headers | the worker overwrites with authorized values | the DO is reachable only through the worker |

Browsers always connect to the alias room my-room, and the worker rewrites it to the room own returned, so no client ever picks a real room name. The grant jwt is minted when the user sends a prompt and is carried by the Claude API back on every MCP request; the LLM cannot choose the token's contents, and rewriting it breaks the signature, so at any moment the agent is sealed inside the one room that was just authorized, for at most maxAge seconds.

How

| entry | main exports | where it runs | | -------------- | -------------------------------------------------------- | ----------------------- | | ai-ax | x | everywhere (isomorphic) | | ai-ax/hono | hono | worker | | ai-ax/server | Server | Durable Object | | ai-ax/client | client, hydrate, createStream | browser | | ai-ax/react | useClient, Client, atom, setAtom, useAtomValue | browser (React) | | ai-ax/const | protocol constants (paths, header names) | both |

The worker expects a Durable Object binding named v1 pointing at the exported Server class, and the environment needs AUTH_SECRET, the OAuth client credentials and ANTHROPIC_API_KEY. Every environment-dependent value is a config resolver of the shape (env) => string, so the app decides which variable to read — domain: (env) => env.AUTH_DOMAIN is only a convention, not a requirement. domain returns the host tail that follows the service name (.glre.dev in production, -dev.glre.dev in staging) and shares the session cookie across every service of that environment: its first . onward becomes the cookie domain and the part before it becomes a per-environment cookie name suffix, so production and staging sessions never mix even under one apex. apiKey resolves the Claude API key the same way and has no built-in variable name at all; secret falls back to AUTH_SECRET only because Auth.js itself owns that convention (as it owns AUTH_URL). Config keys of x(params, config) and hono(ai, extra):

| key | type | default | meaning | | --------- | -------------------------- | ----------------------------------------- | ----------------------------------------------------- | | name | string | 'x' | service id, becomes the MCP server name | | tree | string | generic text | description of the built-in get_tree tool | | own | (at: Peer) => string | deny all | authorization resolver, '' denies | | adapter | (env) => Adapter | none | Auth.js adapter factory, jwt-only when omitted | | lobby | string | none | one anonymous room without persistence | | load | (at: Saved) => buf | none | snapshot restore on first doc load | | save | (at: Saved, buf) => void | none | snapshot persist on debounce / alarm / disconnect | | ui | string[] | [] | volatile keys (trailing / = prefix), no undo / save | | history | object | none | extra Y.UndoManager options (e.g. captureTimeout) | | alias | string | 'my-room' | connection name meaning "my room", rewritten by own | | system | string | '' | system prompt for the LLM | | model | string | 'claude-sonnet-5' | Claude API model id | | claude | string | 'https://api.anthropic.com/v1/messages' | Claude API URL, replaceable with an AI Gateway | | maxTokens | number | 32000 | max_tokens per response | | maxTurns | number | 10 | upper bound of pause_turn continuations | | binding | string | 'v1' | Durable Object binding name | | host | string | request host | public host the Claude API reaches the MCP through | | endpoint | string | '/api/mcp' | MCP endpoint path | | relay | string | '/api/llm' | LLM relay path | | version | string | '1.0.0' | MCP server version string | | salt | string | 'authjs.mcp-token' | grant jwt salt, separates services | | maxAge | number | 43200 (12h) | grant jwt lifetime in seconds | | providers | any[] | Google | Auth.js providers | | domain | (env) => string | none | host tail after the service name, shares the cookie | | apiKey | (env) => string | none | Claude API key resolver | | secret | (env) => string | env.AUTH_SECRET | Auth.js / grant jwt secret resolver |

The x-user-sub / x-user-path header names and the anthropic-version are internal protocol constants and intentionally not configurable, because both ends of each protocol must change together.