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

react-observer-agent

v0.2.0

Published

A React library that enables AI agents to observe application state, understand user context, and execute pre-defined actions through a declarative provider pattern.

Readme

ShowCase

react-observer-agent

A React library that lets an LLM agent observe your app's state, understand what the user is doing, and execute pre-defined actions, all through a declarative <AIAgentProvider> and registered tools, with permission boundaries built in.

Docs and live examples: reactobserveragent.sudo-ezekiel.com

This is an experimental project by a solo developer. I am exploring whether an AI agent can be useful inside a live React app without dumping your whole state into a prompt or letting the model run arbitrary code. It works and it is tested, but it remains a research project rather than a product. See the disclaimer.

  • Zero runtime dependencies (adapters use raw fetch, no SDKs)
  • TypeScript, dual ESM/CJS builds with types included
  • React >= 18 (peer dependency)
  • Works with any state manager: Zustand, Redux, vanilla React state

Install

npm install react-observer-agent

Quick start

import { AIAgentProvider, registerTool, openAIAdapter, useAgent } from 'react-observer-agent';
import { useStore } from './store';

// 1. Register tools: actions the agent is allowed to perform
const tools = [
  registerTool('goToPage', (args: { path: string }) => navigate(args.path), {
    description: 'Navigate to a page in the app',
    parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
  }),
  registerTool('submitForm', () => handleSubmit(), {
    description: 'Submit the current form',
    confirm: true, // requires user approval before executing
  }),
];

// 2. Configure the model adapter (route through your backend in production)
const model = openAIAdapter({
  baseURL: '/api/agent', // your backend proxy holds the real API key
});
// Or Claude, same interface: claudeAdapter({ baseURL: '/api/agent' })

// 3. Wrap your app with the provider
export default function App() {
  return (
    <AIAgentProvider
      model={model}
      state={() => {
        const { user, cart } = useStore.getState();
        return { user, cart };
      }}
      tools={tools}
      permissions={{
        canAccess: ['user', 'cart'],
        canExecute: ['goToPage', 'submitForm'],
        stateDescriptions: {
          user: 'Current logged-in user profile',
          cart: 'Shopping cart items and quantities',
        },
      }}
      options={{
        onConfirm: async (call) => window.confirm(`Allow "${call.toolName}"?`),
      }}
    >
      <YourApp />
    </AIAgentProvider>
  );
}

// 4. Interact with the agent from any component
function ChatPanel() {
  const { send, isProcessing, history } = useAgent();
  // send("What's in my cart?") -> agent reads state, responds with text
  // send("Go to settings")     -> agent calls goToPage({ path: '/settings' })
  // send(text, { signal })     -> pass an AbortSignal to cancel mid-flight
}

The state prop takes either a plain object or a getter function:

// Vanilla React state: pass an object, re-renders keep it fresh
<AIAgentProvider state={{ user, cart }} ... >

// External stores (Zustand, Redux): pass a getter
<AIAgentProvider state={() => useStore.getState()} ... >

One gotcha worth knowing: the getter runs outside React rendering, inside the async agent loop, so it must not call hooks. state={() => useStore.getState()} is correct; state={useStore} passes the hook itself and throws an invalid hook call the first time the agent reads state.

The core idea: pull-based state

State values are never sent to the model upfront. The model receives a manifest, key names plus descriptions, in the system prompt, and pulls specific values on demand through an internal __readState tool:

User: "What's in my cart?"

System prompt lists: user, cart, products (with descriptions)
Agent calls: __readState({ keys: ["cart"] })
Tool returns: { "cart": [{ "product": "Headphones", "qty": 1 }] }
Agent answers: "You have Wireless Headphones in your cart."

Two things fall out of this:

  • Token cost scales with what the agent actually reads, not with the size of your state tree.
  • Unread state never leaves the client. A key the agent does not ask for is never serialized into a request.

__readState is invisible to you as a consumer. It never appears in AgentResponse.toolCalls, the onToolCall callback, or history. The rationale and a worked example are in docs/internals.md.

Security model

The library treats the LLM as an untrusted planner inside a capability sandbox.

Allowlists. canAccess (state keys) and canExecute (tool names) are whitelists. Anything unlisted does not exist from the agent's point of view.

Two enforcement layers. Permissions are checked before and after the model call:

  1. Visibility: the model never sees unlisted keys or tools, so it cannot request what it cannot see.
  2. Execution: names are re-validated after the model responds. A hallucinated or injected tool name is rejected with status denied, and __readState requests are re-filtered against canAccess.

Argument validation. Tool arguments are checked against the tool's parameters JSON Schema before the handler runs, and before the confirmation prompt, so nobody is asked to approve a malformed call. Validation covers a deliberate subset (type, properties, required, items, enum) and ignores keywords outside it, so a richer schema validates on the parts the library understands instead of failing outright. Handlers should still treat args as untrusted, since unvalidated keywords pass through.

Human confirmation. Tools registered with confirm: true route through your onConfirm handler before running. You own the UI: modal, toast, window.confirm, anything that resolves a boolean. If no handler is provided, the tool is skipped with status cancelled. Confirmation is never silently bypassed. Use it for anything irreversible or user-visible.

Prompt injection. State often contains user-generated content (reviews, messages, profile fields). Once serialized into the conversation, that content can attempt prompt injection. The permission and confirmation layers are the backstop: an injected instruction can at worst invoke allowlisted tools, and confirmed tools still require a human yes.

API keys. Passing apiKey to an adapter ships the key to the browser, visible in DevTools. That is for local development only. In production, route through your own backend with baseURL plus headers:

const model = openAIAdapter({
  baseURL: '/api/agent',
  headers: { Authorization: `Bearer ${sessionToken}` },
});

The backend holds the real key, applies auth and rate limits, and forwards to the LLM provider.

Adapters

| Adapter | Status | Defaults | |---------|--------|----------| | openAIAdapter | Built in | OpenAI chat completions; model gpt-4o, temperature 0.2 | | claudeAdapter | Built in | Anthropic Messages API; model claude-opus-5, maxTokens 16000 | | ollamaAdapter | Planned | Local models via Ollama | | Custom | Supported | Implement ModelAdapter and pass it to the provider |

Both built-in adapters are raw fetch, no SDK dependency. Both require either apiKey or baseURL and throw at construction with neither. claudeAdapter sends no sampling parameters, since current Claude models reject them.

How send() behaves

Each send() runs a turn loop of at most options.maxTurns model round trips (default 5). A few behaviors worth knowing:

  • Conversation memory. The prior LLM transcript is replayed with tool calls and their results intact across send() calls, so the agent remembers what it already did. clearHistory() resets it.
  • Cancellation. send(message, { signal }) takes an AbortSignal. Aborts resolve with error.code: 'ABORTED' rather than throwing, and deliberately do not fire onError, since a cancel is a caller decision, not a failure.
  • Turn budget. When maxTurns runs out while the model is still calling tools, send() resolves with error.code: 'MAX_TURNS' and whatever tool calls accumulated.
  • Token usage. AgentResponse.usage totals prompt and completion tokens across every model call in the interaction, when the adapter reports them.

API reference

Everything the package exports:

| Export | What it is | |--------|------------| | AIAgentProvider | Context provider wiring model, state, tools, and permissions together | | useAgent() | Hook to interact with the agent from anywhere in the provider tree | | registerTool(name, handler, options?) | Creates a validated tool definition | | openAIAdapter(config) | OpenAI chat completions adapter | | claudeAdapter(config) | Anthropic Messages API adapter | | validateToolNames, filterState, filterTools, validateToolCall | Building blocks for testing and custom wiring; typical apps never call these | | Types | ModelAdapter, AgentResponse, ToolDefinition, and the rest of src/types.ts |

On registerTool: a tool needs a description to be shown to the model, and omitting parameters substitutes the empty object schema. Names beginning with __ are reserved for internal tools (__readState) and rejected on mount, as are duplicate names.

<AIAgentProvider> props

| Prop | Type | Notes | |------|------|-------| | model | ModelAdapter | Required | | state | object \| (() => object) | Object for React state, getter for external stores | | tools | AnyToolDefinition[] | From registerTool; names must be unique, checked on mount | | permissions | PermissionsConfig | Required, see below | | options | AgentOptions | Optional, see below | | children | React.ReactNode | |

PermissionsConfig

| Field | Type | Notes | |-------|------|-------| | canAccess | string[] | State keys the agent may read | | canExecute | string[] | Tool names the agent may invoke | | stateDescriptions | Record<string, string> | Optional per-key descriptions for the manifest; missing entries fall back to the key name |

AgentOptions

| Field | Type | Notes | |-------|------|-------| | debug | boolean | Verbose console logging, prefixed [react-observer-agent] (default false) | | maxTurns | number | Max LLM round trips per send() (default 5) | | systemPrompt | string | Prepended to the generated state manifest prompt | | onError | (error: AgentError) => void | Called when an interaction fails (except ABORTED) | | onToolCall | (call: ToolCallEvent) => void | Observer for every user-tool outcome | | onConfirm | (call: PendingToolCall) => Promise<boolean> | Approval handler for confirm: true tools |

useAgent() returns

| Field | Type | Notes | |-------|------|-------| | send | (message, options?) => Promise<AgentResponse> | options.signal cancels; resolves rather than rejects on errors | | isProcessing | boolean | True while an interaction is in flight | | history | ConversationEntry[] | User-facing conversation history for this provider instance | | clearHistory | () => void | Resets history, the LLM transcript, and lastResponse | | lastResponse | AgentResponse \| null | Most recent response, including error responses |

Tool call statuses in AgentResponse.toolCalls and onToolCall: success, confirmed, cancelled, denied, error.

The full contracts, including the ModelAdapter interface for writing custom adapters, are in SPEC.md.

What's next

In rough priority order:

  1. ollamaAdapter for local models
  2. Streaming responses
  3. Deeper argument validation
  4. Transcript compaction, so long sessions stay under the context window
  5. Per-tool permission scoping

Explicit non-goals for now: DOM awareness and page context mapping, automatic state detection, multi-agent orchestration, persistent memory, built-in rate limiting.

Docs and examples

Disclaimer

This is a solo experiment. It is not production-ready. It may change, break, or stop at any time.

If you are curious about intelligent UIs, you are welcome to explore it, fork it, or reach out. Feedback is appreciated.

License

MIT