
Research
/Security News
Mini Shai-Hulud Campaign Hits Red Hat Cloud Services npm Packages
A mini Shai-Hulud campaign compromised Red Hat Cloud Services npm packages to steal developer and CI/CD secrets during installation.
@multiplayer-app/ai-agent-react
Advanced tools
Composable React/Vite frontend primitives for Multiplayer AI agents
Composable React/Vite component library that ships the Multiplayer AI Agents UI primitives – chat, model/context controls, tool discovery, reasoning trace, artifacts, and background-agent management hooks. The package is designed to drop into any React (or Vite) web-app and talk either to Multiplayer's proxy backend or directly to an LLM provider such as OpenRouter/OpenAI.
proxy (Multiplayer backend) and direct (OpenRouter/OpenAI/custom) without touching UI code.The package is managed inside the monorepo workspace. From the repo root:
npm install
npm run dev -w ai-agent-react # optional preview sandbox
npm run build -w ai-agent-react
import { AgentProvider, AgentChatWindow } from '@multiplayer-app/ai-agent-react'
const config = {
appId: 'your-app-id',
workspaceId: 'workspace-123',
contextKeys: [
{ key: 'global', label: 'Global', tools: ['kb-search', 'jira'] },
{
key: 'filing',
label: 'Filing Prep',
tools: ['kb-search', 'pdf-draft'],
defaultModel: 'gpt-4o'
}
],
models: [
{ id: 'gpt-4o', label: 'GPT-4o', reasoning: 'concise' },
{ id: 'sonnet', label: 'Claude Sonnet', reasoning: 'deep' }
],
tools: [
{ name: 'kb-search', label: 'Knowledge Base' },
{ name: 'jira', label: 'Jira' }
],
transport: {
mode: 'proxy',
baseUrl: '/api' // never ship raw API keys to the browser
},
features: {
reasoning: 'panel',
artifactsPanel: true,
modelSwitching: true,
toolConfiguration: true
}
}
export const YourAppAgent = () => (
<AgentProvider config={config}>
<AgentChatWindow />
</AgentProvider>
)
The UI supports sending attachments with user messages. One special attachment type is:
AgentAttachmentType.Context: structured “small context” payloads (page snippet, form fields, etc.)security.containsPII and security.redactionsAppliedContext attachments can contain sensitive data (emails, names, phone numbers, access tokens, etc.). If you include the optional metadata.security block:
containsPII: true means “this attachment may contain PII/sensitive data.” This is a signal for your logging/storage/prompt policies; it does not automatically redact or protect anything.redactionsApplied: string[] is an audit trail of what you already scrubbed before attaching it, e.g. ['email', 'phone', 'access_token'].If you set containsPII: true with redactionsApplied: [], you are explicitly saying “may contain sensitive data and we did not redact it.”
config.features toggles.config.composerAttachmentActions slot for host-defined toolbar buttons that add custom attachments.pageContext chip (URL + metadata) by default. Users can remove it; if removed, the composer shows an “Attach page context” button.attachments in SendMessagePayload to your transport (proxy or direct).config.pageContext.getData)If you want to inject host-specific metadata into the built-in pageContext attachment (e.g. tenant id, current record id, feature flags), pass:
const config = {
// ...
features: { pageContextAttachments: true },
pageContext: {
name: 'Page',
getData: ({ url, route, title }) => ({
url,
route,
title,
tenantId: window.__TENANT_ID__
})
}
}
The return value is merged into attachment.metadata.data. Keep it fast and avoid heavy DOM scraping.
Sometimes you don’t have access to the config module where getData is defined. Use the runtime:
import { useAgentRuntime } from '@multiplayer-app/ai-agent-react'
const runtime = useAgentRuntime()
const data = runtime.getPageContextData() // calls config.pageContext.getData(...)
const attachment = runtime.buildPageContextAttachment()
usePageContextMost “page context” is naturally owned by the page/route (record id, active tab, filters). Don’t force that into global config. Instead, register it from the page:
import { usePageContext } from '@multiplayer-app/ai-agent-react'
import { useParams } from 'react-router-dom'
import { useCallback } from 'react'
export function CustomerPage() {
const { customerId } = useParams()
const { customerName } = useCustomer(customerId)
usePageContext(
() => ({
customerId,
customerName
}),
[customerId, customerName]
)
// ...
}
This data is merged into the built-in pageContext attachment’s metadata.data whenever it is attached/refreshed.
You generally don’t call transports directly. You attach context by:
AgentAttachment[]The underlying contract is SendMessagePayload.attachments.
The library exposes a small hook that lets you push attachments into the current composer draft (active chat or draft chat):
import { useComposerDraft } from '@multiplayer-app/ai-agent-react'
const { addAttachments, removeAttachment, clearDraft, draft } = useComposerDraft()
This is the intended way for host apps to attach page/form context from “different places” in the UI.
AgentRuntime exposes a small per-runtime event bus at runtime.events for host integrations (e.g. “apply these form values”).
useRuntimeEventListenerUse this to subscribe to runtime.events with proper cleanup and without re-subscribing every render:
import { useRuntimeEventListener } from '@multiplayer-app/ai-agent-react'
import { FormApplyRequestedEventType, type FormApplyRequestedEvent } from '@multiplayer-app/ai-agent-react'
useRuntimeEventListener<FormApplyRequestedEvent>(FormApplyRequestedEventType, (e) => {
console.log('apply requested', e.detail)
})
useProposeFormValuesThe built-in propose_form_values renderer emits a FormApplyRequestedEventType event when the user clicks Apply/Reject.
In a host app, you should listen for that and mutate your local form state:
import { useProposeFormValues } from '@multiplayer-app/ai-agent-react'
useProposeFormValues({
formId: 'customerOnboarding',
onApply: ({ fields }) => {
// setState(...) based on `fields`
}
})
AttachContextButtonFor convenience, the library also exports an unstyled helper button:
import { AttachContextButton } from '@multiplayer-app/ai-agent-react'
;<AttachContextButton
// Prefer `context` so boilerplate is filled under the hood.
context={() => ({
kind: 'crmRecord',
name: 'CRM context',
summary: 'Renewal in 14 days. Billing escalation open.',
data: { accountId: 'acc_123' }
})}
>
Attach CRM context
</AttachContextButton>
If you need full control, you can also provide a complete attachment object:
import { AttachContextButton } from '@multiplayer-app/ai-agent-react'
import { nanoid } from 'nanoid'
import { AgentAttachmentType } from '@multiplayer-app/ai-agent-types'
;<AttachContextButton
attachment={() => ({
id: nanoid(),
type: AgentAttachmentType.Context,
name: 'CRM context (manual)',
metadata: {
schemaVersion: 1,
kind: 'crmRecord',
capturedAt: new Date().toISOString(),
summary: 'Renewal in 14 days. Billing escalation open.',
data: { accountId: 'acc_123' }
}
})}
>
Attach CRM context (manual)
</AttachContextButton>
createContextAttachment (fills boilerplate under the hood)If you don’t want to manually set id, type, metadata.schemaVersion, metadata.capturedAt, and source.url, use:
import { createContextAttachment } from '@multiplayer-app/ai-agent-react'
const att = createContextAttachment({
kind: 'webSnippet',
url: window.location.href,
selectedText: '…'
})
This produces a valid AgentAttachment with defaults filled in.
config.composerAttachmentActions)To add toolbar buttons in the composer (next to file/selection attach), pass a React component. It receives the current draft attachments and helpers to add or remove attachments:
import {
AgentProvider,
AgentChatWindow,
createContextAttachment,
type ComposerAttachmentActionsProps,
type AgentFrontendConfig
} from '@multiplayer-app/ai-agent-react'
import { Bookmark } from 'lucide-react'
function RecordAttachmentActions({ addAttachments }: ComposerAttachmentActionsProps) {
return (
<button
type="button"
className="mp-agent-composer-button mp-agent-composer-tool-button"
aria-label="Attach current record"
onClick={() => {
addAttachments([
createContextAttachment({
kind: 'record',
title: 'Current record',
selectedText: JSON.stringify({ id: 'rec_123' })
})
])
}}
>
<Bookmark size={16} />
</button>
)
}
const config: AgentFrontendConfig = {
appId: 'your-app',
contextKeys: [{ key: 'default', label: 'Default', tools: [] }],
transport: { mode: 'proxy', baseUrl: '/api' },
features: {
fileAttachments: true,
selectionAttachments: true,
pageContextAttachments: true
},
composerAttachmentActions: RecordAttachmentActions
}
export const YourAppAgent = () => (
<AgentProvider config={config}>
<AgentChatWindow />
</AgentProvider>
)
Use mp-agent-composer-button and mp-agent-composer-tool-button on buttons so they match the built-in composer controls. For attach UI outside the composer toolbar, use useComposerDraft() or AttachContextButton instead.
import { nanoid } from 'nanoid'
import { AgentAttachmentType, SendMessagePayloadSchema } from '@multiplayer-app/ai-agent-types'
const attachments = [
{
id: nanoid(),
type: AgentAttachmentType.Context,
name: 'CRM context',
metadata: {
schemaVersion: 1,
kind: 'crmRecord', // custom kind
capturedAt: new Date().toISOString(),
title: 'Account: Acme Inc.',
summary: 'Renewal in 14 days. Open billing escalation.',
data: { accountId: 'acc_123', health: 'yellow' }
}
}
]
// Optional: validate locally before sending.
SendMessagePayloadSchema.parse({
content: 'Draft a renewal follow-up email.',
contextKey: 'support',
attachments
})
webSnippet attachmentimport { nanoid } from 'nanoid'
import { AgentAttachmentType } from '@multiplayer-app/ai-agent-types'
const selectedText = window.getSelection?.()?.toString()?.trim() ?? ''
const url = window.location.href
const attachments = selectedText
? [
{
id: nanoid(),
type: AgentAttachmentType.Context,
name: 'Selection',
url,
metadata: {
schemaVersion: 1,
kind: 'webSnippet',
capturedAt: new Date().toISOString(),
selectedText,
source: { app: 'browser', url }
}
}
]
: []
// app/api/agents/route.ts
import { NextResponse } from 'next/server'
const AGENT_API = 'https://agents.api.multiplayer.app'
const AGENT_KEY = process.env.AGENTS_API_KEY
export async function POST(req: Request) {
if (!AGENT_KEY) {
return NextResponse.json({ error: 'Missing AGENTS_API_KEY' }, { status: 500 })
}
const upstream = await fetch(`${AGENT_API}/proxy`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${AGENT_KEY}`
},
body: await req.text()
})
return NextResponse.json(await upstream.json(), { status: upstream.status })
}
Host apps should hit /api/agents from the browser and keep provider credentials on the server (or issue short-lived tokens) so the public bundle never contains secrets.
The UI is a thin layer over the runtime primitives inside src/runtime. Every send follows the same deterministic loop:
AgentComposer.tsx) – builds a SendMessagePayload with the active contextKey, composer overrides, and the existing chatId. Pending assistant state is queued in AgentStore so the transcript can optimistically render “Thinking…” bubbles.ProxyTransport or DirectTransport) – the runtime picks the correct transport based on config.transport.mode. ProxyTransport streams Server-Sent Events and enforces timeouts/headers; DirectTransport fabricates an in-memory chat, forwards OpenAI/OpenRouter style payloads, and stitches streaming deltas into a pending assistant message.useAgentStore) – reasoning traces are appended per chat, and once the transport resolves the final AgentChat, upsertChat hydrates the full transcript + artifacts.ToolCallCard.tsx) – any AgentMessage.toolCalls entries are rendered immediately. If you registered a renderer via AgentRuntime.registerToolRenderer, the UI swaps the JSON viewer for your custom card.The runtime never mutates chats outside of these store actions, so you can safely swap in your own components as long as they call the same selectors/actions.
User input
│
▼
AgentComposer ──┐
│ payload (`SendMessagePayload`)
▼
AgentRuntime
│ picks transport
│
│──────────► ProxyTransport → Multiplayer backend (SSE/event stream)
│
└──────────► DirectTransport → Provider API (OpenAI/OpenRouter/custom)
│
▼
AgentStore (Zustand)
│
├─ updates chats/reasoning/tool calls
└─ exposes selectors
│
▼
AgentMessageList / AgentToolShelf / AgentSidebar (render)
User selects tool chip / runtime.tools.invoke(...)
│
▼
AgentRuntime.invokeTool
│ validates chat context
▼
ToolRegistry.execute
│ runs handler (client) or awaits backend status
▼
AgentStore.upsertToolCall
│
▼
ToolCallCard renderer
config object colocated with a Zod schema (or reuse Multiplayer's built-in schema) so CI fails on breaking changes.contextKeys. Each key should map to a clear product surface to avoid combinatorial explosion in tool policies.label, description, and ideally icon + confirmation. Missing metadata means degraded UX.| Field | Purpose |
|---|---|
appId | Tenant/application identifier issued by Multiplayer. Used for auth and metrics. |
workspaceId | Optional per-user workspace scoping. Leave undefined if the backend infers it from the session. |
debug | Enables extra debug UI in the React client (currently: tool-call details for tools without a registered renderer). |
contextKeys | Array of { key, label, tools, defaultModel?, autoConfirmTools? } describing per-surface policies. Mirrors backend contextKey rules. |
defaultContextKey | Forces the initial chat context; otherwise the first item in contextKeys is used. |
tools | Optional registry with metadata (description, icon, category, confirmation, handler). Powers tool shelves and runtime overrides. |
models | Optional catalog with per-model reasoning depth + allowed contexts. Drives the model switcher + default-model hints. |
transport | { mode: 'proxy', baseUrl, headers?, timeoutMs?, onError? } to hit Multiplayer backend, or { mode: 'direct', provider, endpoint?, apiKey?, model?, onError? } for raw LLMs. onError receives an HttpError (with statusCode) on every non-2xx response. |
features | Fine-grained toggles: reasoning panel location, artifact visibility, multi-agent controls, sandbox switches, etc. |
theme | Partial overrides for the default theme tokens (background, accent, font, radius, etc.). |
toolRenderers | Map tool names to React components when tool output needs rich visuals (tables, charts, custom viewers). |
composerAttachmentActions | React component in the composer toolbar for host-defined attachment buttons (addAttachments, removeAttachment, current attachments). |
pageContext | Options and getData hook for the built-in page-context attachment chip. |
user | Optional { id, displayName, email, avatarUrl } shape. If provided, user metadata is attached to tool calls/history. |
All configs are validated through zod at runtime, so invalid configurations fail fast.
<AgentProvider
config={{
...config,
theme: { background: '#050b16', accent: '#2FE6FF', radius: 16 }
}}
>
<AgentChatWindow />
</AgentProvider>
Under the hood AgentThemeProvider exposes CSS variables (--mp-agents-*). If your product already has a design system, wrap AgentChatWindow and override the variables to match the host theme.
Tool metadata is strongly typed and validated in src/config/types.ts:
export interface AgentToolDefinition {
name: string
label: string
description?: string
icon?: string
schema?: Record<string, unknown>
confirmation?: 'auto' | 'manual'
category?: string
handler?: AgentToolInvoke
}
export type AgentToolCall = {
id: string
name: string
input: Record<string, unknown>
status: 'pending' | 'running' | 'succeeded' | 'failed'
output?: Record<string, unknown>
error?: string
requiresConfirmation?: boolean
}
The UI never guesses fields—if handler is omitted, the tool is display-only; if the backend emits requiresConfirmation, the shelf can branch into your own approval UX.
AgentToolShelf filters the runtime registry against the active contextKey -> tools list. Chips are pure metadata.AgentMessage.toolCalls. The store keeps those calls as-is (including status, error), which means you must propagate the tool status from the server.ToolCallCard asks AgentRuntime for a renderer. If none exists, it falls back to a JSON viewer so the transcript always stays auditable.handler via runtime.tools.register, the invocation happens purely on the client (synchronous or async) and the resulting AgentToolCall is returned to the caller. This is ideal for local helpers (opening drawers, querying browser APIs) but you should keep privileged work on the server.// tooling/registry.ts
import type { AgentToolDefinition } from '@multiplayer-app/ai-agent-react'
export const baseTools: AgentToolDefinition[] = [
{
name: 'kb-search',
label: 'Knowledge Base',
description: 'Semantic search across product docs',
icon: 'search',
confirmation: 'auto'
},
{
name: 'jira',
label: 'Jira Issues',
description: 'Open, triage, or update tickets',
icon: 'jira',
confirmation: 'manual'
}
]
Feed the array into config.tools. Context gating still happens via each ContextKeyConfig.tools array, so a tool never surfaces outside the contexts you list there.
To register ad-hoc client helpers, reach for runtime.registerTool (a thin wrapper over ToolRegistry):
runtime.registerTool({
name: 'copy-last-answer',
label: 'Copy answer to clipboard',
handler: async (_input, ctx) => {
const chat = runtime.store.getState().chats[ctx.chatId]
const lastAssistant = chat?.messages
.slice()
.reverse()
.find((m) => m.role === 'assistant')
if (lastAssistant) {
await navigator.clipboard.writeText(lastAssistant.content)
ctx.appendSystemMessage('Copied last assistant response to clipboard.')
}
}
})
// pages/api/agents/tools/jira.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { jiraClient } from '@/lib/jira'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end()
const { input, contextKey, actor } = req.body
if (contextKey !== 'filing') {
return res.status(403).json({ error: 'Jira disabled for this context' })
}
try {
const issue = await jiraClient.search(input.query, { assignee: actor.id })
return res.json({
output: issue,
observations: [`Returned ${issue.total} issues`]
})
} catch (err) {
console.error(err)
return res.status(502).json({
error: 'Jira search failed',
observations: [err instanceof Error ? err.message : 'Unknown Jira error']
})
}
}
When using the Multiplayer proxy, mirror the same policies server-side via your /tools definitions so the UI never drifts from backend enforcement.
import { AgentProvider, AgentRuntime, AgentSidebar } from '@multiplayer-app/ai-agent-react'
import { baseTools } from './tooling/registry'
const runtime = new AgentRuntime({ ...config, tools: baseTools })
runtime.tools.register({
name: 'feature-flags',
label: 'Toggle FF',
description: 'Flip LaunchDarkly flags scoped to the current workspace',
onInvoke: async ({ input, context }) => {
try {
const resp = await fetch('/api/feature-flags/toggle', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...input, context })
})
if (!resp.ok) {
throw new Error(`Toggle failed with ${resp.status}`)
}
return resp.json()
} catch (error) {
context.appendSystemMessage(
`Feature-flag tool failed: ${error instanceof Error ? error.message : 'Unknown error'}`
)
throw error
}
}
})
export function AgentsPanel() {
return (
<AgentProvider runtime={runtime}>
<AgentSidebar />
</AgentProvider>
)
}
runtime.tools.register accepts inline handlers (onInvoke) for front-end only tools (e.g., opening a modal, reading local storage) or can proxy to your server routes.
AgentToolShelf exposes the selected AgentToolDefinition before execution so you can gate, hydrate, or redirect calls:
import { AgentToolShelf, useAgentRuntime } from '@multiplayer-app/ai-agent-react'
export const ToolShelfWithReview = () => {
const runtime = useAgentRuntime()
const handleToolSelected = async (tool) => {
if (tool.name === 'prod-deploy') {
const ok = window.confirm('Ship to production?')
if (!ok) return
}
runtime.tools.invoke(tool.name, { target: 'production' })
}
return <AgentToolShelf onSelectTool={handleToolSelected} />
}
The transcript renders every tool call as a dedicated card. Provide custom components when you want richer visuals (charts, tables, external viewers):
import type { ToolRendererProps } from '@multiplayer-app/ai-agent-react';
const FilingChartRenderer = ({ call }: ToolRendererProps) => {
const rows = call.output?.rows as Array<{ label: string; value: number }>;
if (!rows) return null;
return (
<div style={{ display: 'flex', gap: 12 }}>
{rows.map((row) => (
<div key={row.label} style={{ flex: 1 }}>
<div style={{ fontSize: 12, opacity: 0.7 }}>{row.label}</div>
<div style={{ fontSize: 28, fontWeight: 600 }}>{row.value}</div>
</div>
))}
</div>
);
};
const config = {
...,
toolRenderers: {
'filing-stats': FilingChartRenderer
}
};
At runtime you can register or override a renderer:
const runtime = new AgentRuntime(config)
runtime.registerToolRenderer('jira-search', JiraTableRenderer)
If no renderer is provided, the library falls back to a simple tool status panel.
When config.debug: true and a tool has no renderer, the tool card shows a collapsible Details panel with pretty-printed JSON for:
inputoutputstatus / errorAgentRuntime handlers (msw highly recommended).try/catch and emit actionable observations so the agent transcript preserves failure context.res.status(502).json({ error: 'Jira search failed' })) to avoid leaking stack traces.appendSystemMessage or runtime.store.setError(...) so operators know a tool degraded.run(agent, prompt). Your backend decides when to call tools, switch contexts, or stop the loop, and the UI simply mirrors whatever the transport returns.If you need OpenAI-style agent handoffs, build them into your backend transport first—once your API emits the intermediate messages/tool calls, the React package will visualize them without extra work.
| Mode | Recommended when | Notes |
|---|---|---|
proxy | The host app can reach Multiplayer's backend (preferred). | Enables background agents, artifacts, storage APIs, and server-side tool execution. |
direct | On-prem or hybrid deployments that must talk straight to a provider. | The library stores chats in-memory and calls OpenRouter/OpenAI style APIs. Ideal for single-page use cases or POCs. |
The runtime is intentionally created once inside AgentProvider and reused. For production integrations you often need to update transport headers at runtime (auth refresh, tenant switch, tracing).
Use the runtime APIs instead of trying to mutate the original config object.
// Replace (or clear) the headers map
runtime.setTransportHeaders({ Authorization: `Bearer ${token}`, 'x-tenant': tenantId })
runtime.setTransportHeaders(undefined) // clears
// Merge/override a few keys
runtime.mergeTransportHeaders({ 'x-trace-id': traceId })
// Remove keys
runtime.removeTransportHeaders(['Authorization', 'x-trace-id'])
runtime.updateTransportConfig({
...runtime.config.transport,
// proxy example
headers: { ...(runtime.config.transport.headers ?? {}), Authorization: `Bearer ${token}` }
})
onError)Provide onError in the transport config to intercept every HTTP and streaming error. The callback receives an HttpError instance that exposes the raw statusCode alongside the human-readable message:
import { HttpError } from ‘@multiplayer-app/ai-agent-react’
const config = {
transport: {
mode: ‘proxy’,
baseUrl: ‘/api’,
onError: (error) => {
if (error instanceof HttpError) {
if (error.statusCode === 401) return redirectToLogin()
if (error.statusCode === 429) return toast.warn(‘Rate limit reached, try again shortly.’)
}
toast.error(error.message)
}
}
}
The callback fires before the error is re-thrown, so your existing try/catch / React Query error boundaries are still triggered.
apiKey / headers / baseUrl change so realtime updates stay authorized.transport.mode (proxy ↔ direct) replaces the transport instance (active streams are cancelled).AgentMessageList and handing it custom markdown/render logic.AgentComposer, AgentToolShelf, or AgentSidebar individually when embedding within an existing layout.useAgentRuntime() + useAgentStore() to build custom widgets (e.g., active-agent dashboards, analytics, or workspace switchers)./agents APIs.FAQs
Composable React/Vite frontend primitives for Multiplayer AI agents
We found that @multiplayer-app/ai-agent-react demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 5 open source maintainers collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Research
/Security News
A mini Shai-Hulud campaign compromised Red Hat Cloud Services npm packages to steal developer and CI/CD secrets during installation.

Research
/Security News
The North Korean malware loader hides in a Packagist-listed package and its GitHub branch to fetch and execute remote code in a likely Contagious Interview-style lure.

Security News
The Rust project is moving toward formal rules on LLM use in contributions after months of internal debate over maintainer burden, code quality, and contributor experience.