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

@eetr/agent-streemr-react

v0.1.5

Published

React hooks and components for @eetr/agent-streemr — connects a Socket.io agent to React UI.

Readme

@eetr/agent-streemr-react

React hooks for connecting to an agent-streemr server.

Wraps a Socket.io v4 connection with idiomatic React primitives: deferred connect, streaming message accumulation, pluggable local tool handling, and an optional context provider for tree-wide shared state.


Installation

npm install @eetr/agent-streemr-react socket.io-client
# react >=18 is a peer dependency

Quick start

import { useAgentStream } from "@eetr/agent-streemr-react";

function Chat({ jwt, deviceId }: { jwt: string; deviceId: string }) {
  const { connect, sendMessage, messages, status, isStreaming } = useAgentStream({
    url: "http://localhost:8080",
    token: jwt,
  });

  // Connect once the identity is known (deferred — safe with async auth).
  useEffect(() => {
    connect(deviceId);
  }, [deviceId]);

  return (
    <div>
      <p>Status: {status}</p>

      {messages.map((m) => (
        <div key={m.id} className={m.role}>
          {m.content}
          {m.streaming && <span> ▌</span>}
        </div>
      ))}

      {isStreaming && <p>Agent is typing…</p>}

      <button onClick={() => sendMessage("Hello!")}>Send</button>
    </div>
  );
}

Concepts

Deferred connect

The hook does not open a socket on mount. Call connect(threadId) when you have the user's identity — typically after an auth state change. This maps threadId to auth.thread_id in the Socket.io handshake, which the server uses as both the conversation checkpointing key and the socket room.

const { connect } = useAgentStream({ url, token });

// Fires once JWT and deviceId are resolved from auth state
useEffect(() => {
  if (jwt && deviceId) connect(deviceId);
}, [jwt, deviceId]);

Streaming messages

Assistant messages accumulate in-place while streaming. Each AgentMessage has a streaming: boolean flag you can use to show a cursor or typing indicator:

{messages.map((m) => (
  <p key={m.id}>
    {m.content}{m.streaming && <span className="cursor">▌</span>}
  </p>
))}

Providing context to the agent

Call setContext(data) at any time to push a plain JSON object to the server. The server invokes its onContextUpdate callback with the data, letting the application merge or replace fields on its per-thread context before the next agent run. Common uses include the current view state, loaded resources, or user preferences that the agent should be aware of.

const { connect, sendMessage, setContext } = useAgentStream({ url, token });

// After loading a record, push it into the agent's context
useEffect(() => {
  if (recipe) {
    setContext({ currentRecipe: recipe });
  }
}, [recipe]);

The server side must declare an onContextUpdate handler to act on this data:

createAgentSocketListener({
  createContext: () => ({ currentRecipe: null }),
  onContextUpdate(context, data) {
    Object.assign(context, data);
  },
  // …
});

Reasoning / internal tokens

Reasoning tokens (agent "thinking" output) arrive as internal_token events and are accumulated into the separate internalThought string. This is reset on every sendMessage call.

const { internalThought } = useAgentStream({ url, token });

// Render in a collapsible "Thinking…" panel
{internalThought && (
  <details>
    <summary>Thinking…</summary>
    <pre>{internalThought}</pre>
  </details>
)}

Local tool handling

The server can request the client to execute a tool. Each tool gets its own useLocalToolHandler hook instance.

import { useLocalToolHandler } from "@eetr/agent-streemr-react";

function MyApp() {
  const { connect, socket, ...stream } = useAgentStream({ url, token });

  // Handle a "get_location" tool request from the server
  useLocalToolHandler(socket, "get_location", async (_args) => {
    const coords = await navigator.geolocation.getCurrentPosition(/* … */);
    return { response_json: { lat: coords.latitude, lng: coords.longitude } };
  });

  // Handle a "read_clipboard" tool that the user can deny
  useLocalToolHandler(socket, "read_clipboard", async (_args) => {
    const text = await navigator.clipboard.readText();
    return { response_json: { text } };
  });

  // Catch-all: reply notSupported for any tool this client doesn't handle
  useLocalToolFallback(socket);
}

Handler return values

| Return value | Meaning | |---|---| | { response_json: object } | Success — payload forwarded to the agent | | { allowed: false } | User denied the request | | { notSupported: true } | Client doesn't support this tool | | { error: true; errorMessage?: string } | Execution failed |


AllowList: user-controlled tool permissions

Gate tool execution behind an explicit permission list. useInMemoryAllowList provides React-state-backed allow/deny management; swap in your own AllowList implementation for persistence or server-side policy.

Using the built-in in-memory list

import {
  useAgentStream,
  useLocalToolHandler,
  useInMemoryAllowList,
} from "@eetr/agent-streemr-react";

function App() {
  const { socket } = useAgentStream({ url, token });
  const { allowList, allow, deny, entries } = useInMemoryAllowList();

  useLocalToolHandler(socket, "read_file", async ({ path }) => {
    const content = await fs.readFile(path as string, "utf8");
    return { response_json: { content } };
  }, { allowList });

  return (
    <>
      {Object.entries(entries).map(([tool, permitted]) => (
        <div key={tool}>
          {tool}: {permitted ? "✅" : "❌"}
          <button onClick={() => allow(tool)}>Allow</button>
          <button onClick={() => deny(tool)}>Deny</button>
        </div>
      ))}
    </>
  );
}

Interactive per-request approval (async check)

Because check() can return Promise<AllowListDecision>, you can suspend a tool call until the user explicitly clicks Allow or Deny in the UI:

import { useCallback, useMemo, useRef, useState } from "react";
import type { AllowList, AllowListDecision } from "@eetr/agent-streemr-react";

function useInteractiveAllowList() {
  const [pending, setPending] = useState<{ id: string; toolName: string; args: object }[]>([]);
  const resolversRef = useRef<Map<string, (d: AllowListDecision) => void>>(new Map());

  const allowList = useMemo<AllowList>(() => ({
    check(toolName, args): Promise<AllowListDecision> {
      return new Promise((resolve) => {
        const id = crypto.randomUUID();
        resolversRef.current.set(id, resolve);
        setPending((prev) => [...prev, { id, toolName, args }]);
      });
    },
  }), []);

  const approve = useCallback((id: string) => {
    resolversRef.current.get(id)?.('allowed');
    resolversRef.current.delete(id);
    setPending((prev) => prev.filter((p) => p.id !== id));
  }, []);

  const deny = useCallback((id: string) => {
    resolversRef.current.get(id)?.('denied');
    resolversRef.current.delete(id);
    setPending((prev) => prev.filter((p) => p.id !== id));
  }, []);

  return { allowList, pending, approve, deny };
}

Each pending entry maps to an inline UI card. useLocalToolHandler already awaits the result of check(), so the agent is suspended until the user acts.

See agent-streemr-sample for a full production implementation including LocalStorage-backed "Remember for this tool" persistence.

Supplying a custom AllowList

Implement the AllowList interface for server-side, persisted, or interactive policy. check() can return a Promise, which suspends the tool handler until the promise resolves — allowing interactive UIs where the user approves each call:

import type { AllowList } from "@eetr/agent-streemr-react";

// Server-side / async policy check
const policyList: AllowList = {
  async check(toolName, args) {
    const allowed = await myApi.checkPermission(toolName, args);
    return allowed ? "allowed" : "denied";
  },
};

Decisions:

  • "allowed" → handler is called.
  • "denied" → auto-reply { allowed: false } to the server; handler skipped.
  • "unknown" → treated as "denied" (explicit opt-in required).

Context provider

Use AgentStreamProvider when multiple components in a subtree need access to the same stream without prop drilling:

import {
  AgentStreamProvider,
  useAgentStreamContext,
} from "@eetr/agent-streemr-react";

// Root
function Root({ jwt, deviceId }: { jwt: string; deviceId: string }) {
  return (
    <AgentStreamProvider url="http://localhost:8080" token={jwt}>
      <App deviceId={deviceId} />
    </AgentStreamProvider>
  );
}

// Anywhere in the tree
function MessageList() {
  const { messages, isStreaming } = useAgentStreamContext();
  return (/* … */);
}

function InputBar({ deviceId }: { deviceId: string }) {
  const { connect, sendMessage, status } = useAgentStreamContext();
  useEffect(() => { connect(deviceId); }, [deviceId]);
  return (/* … */);
}

Full example: chat UI with tool permissions

import React, { useEffect, useState } from "react";
import {
  AgentStreamProvider,
  useAgentStreamContext,
  useLocalToolHandler,
  useLocalToolFallback,
  useInMemoryAllowList,
} from "@eetr/agent-streemr-react";

// Wrap at root
export function AppRoot({ jwt, deviceId }: { jwt: string; deviceId: string }) {
  return (
    <AgentStreamProvider url="http://localhost:8080" token={jwt}>
      <ChatApp deviceId={deviceId} />
    </AgentStreamProvider>
  );
}

function ChatApp({ deviceId }: { deviceId: string }) {
  const { connect, sendMessage, clearContext, messages, status, internalThought, socket } =
    useAgentStreamContext();

  const { allowList, allow, deny, entries } = useInMemoryAllowList();
  const [input, setInput] = useState("");

  useEffect(() => { connect(deviceId); }, [deviceId]);

  // Grant location access by default
  useEffect(() => { allow("get_location"); }, []);

  useLocalToolHandler(socket, "get_location", async () => {
    return new Promise((resolve) => {
      navigator.geolocation.getCurrentPosition(
        (pos) => resolve({ response_json: { lat: pos.coords.latitude, lng: pos.coords.longitude } }),
        () => resolve({ error: true, errorMessage: "Geolocation denied" })
      );
    });
  }, { allowList });

  useLocalToolFallback(socket);

  const handleSend = () => {
    if (!input.trim()) return;
    sendMessage(input.trim());
    setInput("");
  };

  return (
    <div>
      <header>
        <span>Status: {status}</span>
        <button onClick={clearContext}>Clear</button>
      </header>

      {internalThought && (
        <details>
          <summary>Thinking…</summary>
          <pre>{internalThought}</pre>
        </details>
      )}

      <main>
        {messages.map((m) => (
          <div key={m.id} className={`message ${m.role}`}>
            <strong>{m.role === "user" ? "You" : "Agent"}</strong>
            <p>{m.content}{m.streaming && " ▌"}</p>
          </div>
        ))}
      </main>

      <footer>
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && handleSend()}
          placeholder="Type a message…"
        />
        <button onClick={handleSend} disabled={status !== "connected"}>
          Send
        </button>
      </footer>
    </div>
  );
}

Attachment uploads

Pass an array of Attachment objects as the third argument to sendMessage. The hook automatically performs the full multi-step handshake (start_attachments → N × attachment → wait for acks → message) before the agent run begins:

import type { Attachment } from "@eetr/agent-streemr-react";

// Convert a File to base64, then send with message
async function sendWithImage(file: File) {
  const body = await fileToBase64(file);
  const attachment: Attachment = { type: "image", body, name: file.name };
  sendMessage("Here is a photo of my dish.", undefined, [attachment]);
}

function fileToBase64(file: File): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve((reader.result as string).split(",")[1]);
    reader.onerror = reject;
    reader.readAsDataURL(file);
  });
}

The upload respects attachmentAckTimeoutMs (default 10 s per attachment). The server enforces maxMessageSizeBytes on each attachment body and rejects oversized uploads.


Inactivity timeout

When the server closes a connection due to inactivity it emits inactive_close before disconnecting. The hook captures the reason in inactiveCloseReason and transitions status to "disconnected":

const { inactiveCloseReason, connect, status } = useAgentStream({ url, token });

// Show a banner when the server closed the connection due to inactivity
{inactiveCloseReason && (
  <div className="banner">
    Session ended: {inactiveCloseReason}
    <button onClick={() => connect(threadId)}>Reconnect</button>
  </div>
)}

inactiveCloseReason is cleared automatically on the next connect() call.


API reference

useAgentStream(options)

Options:

| Prop | Type | Description | |---|---|---| | url | string | Socket.io server URL | | token | string | Bearer JWT passed as auth.token | | agentId? | string | Optional agent identifier forwarded in client_hello for server-side routing | | inactivityTimeoutMs? | number | Requested inactivity timeout sent to the server (0 = no timeout) | | attachmentAckTimeoutMs? | number | Per-attachment ack wait before upload fails (default: 10 000 ms) | | socketOptions? | Partial<ManagerOptions & SocketOptions> | Extra Socket.io options |

Returns: UseAgentStreamResult

| Field | Type | Description | |---|---|---| | connect | (threadId: string) => void | Open the socket; maps threadIdauth.thread_id | | disconnect | () => void | Disconnect and reset all state | | sendMessage | (text: string, context?: Record<string, any>, attachments?: Attachment[]) => void | Optimistic send; performs multi-step upload handshake when attachments are provided | | clearContext | () => void | Emit clear_context; wipes local messages on confirmation | | setContext | (data: Record<string, any>) => void | Emit set_context; server calls onContextUpdate with the data | | messages | AgentMessage[] | Conversation history | | status | ConnectionStatus | "disconnected" \| "connecting" \| "connected" \| "error" | | internalThought | string | Accumulated reasoning tokens for the current turn | | isStreaming | boolean | true while an assistant message is being streamed | | serverCapabilities | { max_message_size_bytes: number; inactivity_timeout_ms: number } \| undefined | Capabilities reported by the server after handshake | | inactiveCloseReason | string \| null | Set when the server closes the connection due to inactivity; cleared on reconnect | | error | string \| null | Last error message | | socket | Socket \| null | Raw typed Socket.io socket (for useLocalToolHandler) |


useLocalToolHandler(socket, toolName, handler, options?)

| Param | Type | Description | |---|---|---| | socket | Socket \| null | From useAgentStream return value | | toolName | string | Tool name to handle (must match server tool_name) | | handler | (args: object) => LocalToolHandlerResult \| Promise<…> | Called when a matching tool request arrives | | options.allowList? | AllowList | Optional permission gate |


useLocalToolFallback(socket)

Catch-all that auto-replies { notSupported: true } for any local_tool event not claimed by a useLocalToolHandler. Mount once near the root of your tree.


useInMemoryAllowList()

Returns: InMemoryAllowListResult

| Field | Type | Description | |---|---|---| | allowList | AllowList | Pass to useLocalToolHandler options | | allow(toolName) | (string) => void | Permit a tool | | deny(toolName) | (string) => void | Block a tool | | remove(toolName) | (string) => void | Remove (reverts to "unknown") | | clear() | () => void | Wipe all entries | | entries | Record<string, boolean> | Snapshot for UI rendering |


AgentStreamProvider / useAgentStreamContext()

Accepts the same props as UseAgentStreamOptions plus children. Provides the full UseAgentStreamResult to all descendants via useAgentStreamContext().


Types

type AgentMessage = {
  id: string;
  role: "user" | "assistant";
  content: string;
  streaming: boolean;
};

type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";

type Attachment = {
  type: "image" | "markdown";
  body: string;   // Base64-encoded content
  name?: string;  // Optional filename
};

type LocalToolHandlerResult =
  | { response_json: object }
  | { allowed: false }
  | { notSupported: true }
  | { error: true; errorMessage?: string };

type AllowListDecision = "allowed" | "denied" | "unknown";

interface AllowList {
  // May return a Promise — the handler is suspended until it resolves.
  // This enables interactive per-request approval UIs.
  check(toolName: string, args: object): AllowListDecision | Promise<AllowListDecision>;
}