@askable-ui/react
v0.15.0
Published
React hooks to give AI assistants real-time context about what users see and select
Maintainers
Readme
@askable-ui/react
React bindings for askable — give your UI components LLM awareness in one line.
Install
npm install @askable-ui/react @askable-ui/coreQuick Start
import { Askable, useAskable, useAskableRegionCapture } from '@askable-ui/react';
// Wrap any element to make it LLM-aware
function Dashboard() {
return (
<Askable meta={{ chart: 'revenue', period: 'Q3', value: '$2.3M' }}>
<RevenueChart />
</Askable>
);
}
// Access focus context anywhere in your tree
function AIChatInput() {
const { focus, promptContext, ctx } = useAskable();
const capture = useAskableRegionCapture({
ctx,
onCapture: (packet) => sendToAgent(packet),
});
async function handleSubmit(question: string) {
const res = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify(await ctx.toAgentRequest(question, {
history: 3,
packet: true,
})),
});
return res.json();
}
return (
<div>
<button onClick={() => capture.start({ shape: 'circle' })}>
Circle context
</button>
{focus && <p>Asking about: {JSON.stringify(focus.meta)}</p>}
<input onKeyDown={(e) => e.key === 'Enter' && handleSubmit(e.currentTarget.value)} />
</div>
);
}API
<Askable meta={...} as="div">
Renders any element (default: div) with a data-askable attribute. The meta prop accepts an object or string.
<Askable meta={{ widget: 'churn-rate', value: '4.2%' }} as="section">
<ChurnChart />
</Askable>
<Askable meta={{ widget: 'revenue-chart' }} scope="analytics">
<RevenueChart />
</Askable>
<Askable meta={{ widget: 'pipeline-chart' }} events={['hover']}>
<PipelineChart />
</Askable>
<Askable meta="main navigation" as="nav">
<NavLinks />
</Askable>Props:
meta— structured metadata attached to the element (Record<string, unknown> | string)scope— optional category written todata-askable-scopefor scoped prompt/history queriesevents— optional per-component activation override (AskableEvent[] | 'manual')as— HTML tag to render (default:"div")- All other props are forwarded to the underlying element
Use events when one annotated component should be hover-only, click-only, or fully manual within the same page/context.
function MixedDashboard() {
const cardRef = useRef<HTMLDivElement>(null);
const { ctx } = useAskable();
return (
<>
<Askable meta={{ widget: 'pipeline' }} events={['hover']}>
<PipelineCard />
</Askable>
<Askable meta={{ widget: 'revenue' }} events={['click']}>
<RevenueCard />
</Askable>
<Askable ref={cardRef} meta={{ widget: 'account-summary' }} events="manual">
<AccountSummary />
<button onClick={() => cardRef.current && ctx.select(cardRef.current)}>
Ask AI
</button>
</Askable>
</>
);
}AskableInspector(props?)
Declarative React wrapper around the core inspector.
import { AskableInspector } from '@askable-ui/react';
<AskableInspector events={['click']} />Pass the same events, name, viewport, or ctx that your React app uses for useAskable() when the inspector should follow the same context.
Set sourcePreview when the dev panel should resolve registered source data in
the prompt preview and Copy output.
function DevInspector() {
useAskable({ events: ['click'] });
return process.env.NODE_ENV === 'development'
? <AskableInspector events={['click']} position="bottom-left" sourcePreview />
: null;
}useAskable(options?)
Returns reactive focus state from the shared global AskableContext.
const { focus, promptContext, ctx } = useAskable();
// Restrict which interactions trigger a context update
const { focus: clickFocus } = useAskable({ events: ['click'] });
const { focus: hoverFocus } = useAskable({ events: ['hover'] });
const { focus: focusOnly } = useAskable({ events: ['focus'] });Options:
name?: string— optional shared context name for region-specific context reuseviewport?: boolean— enable viewport-aware context tracking for this hook's contextevents?: AskableEvent[]— trigger events:'click','hover','focus'. Defaults to all three.ctx?: AskableContext— provide a custom context instead of the shared singleton
Returns:
focus: AskableFocus | null— current focus statepromptContext: string— natural language string ready to inject into LLM promptsctx: AskableContext— the underlying context instance for advanced use:ctx.select(el)— programmatically set focus ("Ask AI" button pattern)ctx.clear()— reset focus to nullctx.getHistory(limit?)— focus history, newest firstctx.toHistoryContext(limit?, options?)— history as a prompt-ready stringctx.toPromptContext(options?)— full serialization options (format, maxTokens, excludeKeys, …)ctx.toPromptContextAsync(options?)— include async app-owned sourcesctx.toAgentRequest(question, options?)— package a user question with prompt context, focus, and an optional Context packetctx.subscribeAsync(callback, options?)— stream source-backed context updates with stale result protectionctx.serializeFocus(options?)— structuredAskableSerializedFocusobjectctx.toContextPacket()— structured Context packet for agents and MCP bridges
useAskableSource()— lifecycle-managed app-owned source registrationuseAskableRegionCapture()— explicit region/circle/lasso capture for visual page selectionuseAskableTextSelectionCapture()— explicit highlighted text capture for page copy selection
The hook manages a shared singleton context per name + events + viewport configuration. Multiple useAskable() consumers with the same shared configuration reuse one observer lifecycle, while differing configurations get isolated shared contexts of their own. Each shared context is automatically destroyed when its last consumer unmounts.
If you need isolation, create your own context and pass it through ctx:
const panelCtx = createAskableContext();
panelCtx.observe(panelEl, { events: ['hover'] });
function AnalyticsPanelChat() {
const { promptContext } = useAskable({ ctx: panelCtx });
return <textarea defaultValue={promptContext} />;
}SSR note
useAskable() is safe to call in SSR frameworks such as Next.js. Observation starts on the client after mount, not during server render.
"Ask AI" button pattern
Use ctx.select() to set context explicitly when a user clicks a button, instead of relying on hover or focus:
function RevenueCard({ data }) {
const { ctx } = useAskable();
const cardRef = useRef<HTMLDivElement>(null);
return (
<Askable meta={data} ref={cardRef}>
<RevenueChart data={data} />
<button onClick={() => { ctx.select(cardRef.current!); openChat(); }}>
Ask AI ✦
</button>
</Askable>
);
}App-owned sources
Use useAskableSource() when the assistant needs data that is not fully
rendered in the DOM: paginated tables, virtualized lists, documents, charts,
maps, calendars, canvases, or custom product state. The hook registers the
source after mount, keeps the latest resolver implementation current, and
unregisters automatically on unmount.
import { useEffect } from 'react';
import { useAskableSource } from '@askable-ui/react';
function AccountsSource({ table, filters, sort }) {
const accounts = useAskableSource('accounts', {
kind: 'collection',
describe: 'Customer accounts matching the active filters',
getState: () => ({
filters,
sort,
page: table.getState().pagination.pageIndex + 1,
pageSize: table.getState().pagination.pageSize,
totalCount: table.options.meta?.totalCount,
}),
resolve: async ({ mode, maxItems }) => {
if (mode === 'visible') return table.getRowModel().rows.map((row) => row.original);
return summarizeAccounts({ filters, sort, maxItems });
},
sanitize: (source) => ({
...source,
data: redactAccountFields(source.data),
}),
});
async function askAboutAccounts(question: string) {
const promptContext = await accounts.toPromptContext({
source: { mode: 'summary', maxItems: 20, timeoutMs: 750 },
sourceErrorMode: 'include',
});
return sendToAgent({ question, promptContext });
}
useEffect(() => {
return table.onStateChange(() => {
accounts.notifyChanged();
});
}, [accounts.notifyChanged, table]);
return null;
}Use ctx.toPromptContext() for synchronous focus-only prompts. Use
toPromptContextAsync() or the hook's toPromptContext() helper when the
assistant should include resolver-backed application data.
Call notifyChanged() when source data changes without a DOM focus change,
such as pagination, filters, selected rows, or query-cache updates. Async
subscribers created with ctx.subscribeAsync() re-resolve matching sources.
Region, circle, and lasso capture
Use useAskableRegionCapture() when a user should select an area of the page
and send that geometry as structured context.
import { useAskable, useAskableRegionCapture } from '@askable-ui/react';
function RegionTools() {
const { ctx } = useAskable({ viewport: true });
const capture = useAskableRegionCapture({
ctx,
includeViewport: true,
selectionAffordance: {
label: 'Selected context',
dismissible: true,
prompt: {
placeholder: 'Ask about this area...',
initialValue: 'What should I notice here?',
onSubmit(question, packet) {
sendToAgent({ question, context: packet });
},
},
},
theme: {
lassoStrokeWidth: 4,
lassoGlowRadius: 12,
},
onCapture(packet) {
sendToAgent(packet);
},
});
return (
<>
<button onClick={() => capture.start({ shape: 'region' })}>
Select region
</button>
<button onClick={() => capture.start({ shape: 'circle' })}>
Circle area
</button>
<button onClick={() => capture.start({ shape: 'lasso' })}>
Lasso area
</button>
{capture.active && <button onClick={capture.cancel}>Cancel</button>}
{capture.selectionState && (
<SelectionComposer
packet={capture.selectionState.packet}
selection={capture.selectionState.selection}
onClear={capture.clearSelection}
/>
)}
</>
);
}The lasso overlay ships with the core ASKABLE_REGION_CAPTURE_THEME. Pass
theme through useAskableRegionCapture() to override the overlay,
region/circle fill, or lasso gradient for your app.
Use selectionAffordance to keep the selected area visible and optionally show
an anchored prompt that focuses by default. Pass dismissible: true to include
a built-in clear button. Read capture.selectionState when React should render
a confirmation chip, inline question input, or custom composer for the pinned
selection. Call capture.getSelection() when non-render code needs the current
pinned packet, selection geometry, and affordance element.
Use onSelectionChange(state) to mirror that pinned context into external
state; it receives null when the selection is cleared.
Use once: false when the capture control should stay active for repeated
region, circle, or lasso selections. The hook keeps active true until
cancel() or destroy() runs.
Text selection capture
Use useAskableTextSelectionCapture() when a user highlights text and sends
that exact selection to an agent.
import { useAskableTextSelectionCapture } from '@askable-ui/react';
function SelectionTools() {
const selection = useAskableTextSelectionCapture({
includeViewport: true,
intent: 'answer using the highlighted text',
selectionAffordance: {
label: 'Selected text',
dismissible: true,
prompt: {
placeholder: 'Ask about this text...',
initialValue: 'Explain this quote',
onSubmit(question, packet) {
sendToAgent({ question, context: packet });
},
},
},
onCapture(packet) {
sendToAgent(packet);
},
});
return (
<>
<button onClick={() => selection.start()}>Watch selection</button>
<button onClick={() => selection.captureNow()}>Send selected text</button>
{selection.active && <button onClick={selection.cancel}>Cancel</button>}
{selection.selectionState && (
<SelectedTextComposer
packet={selection.selectionState.packet}
selection={selection.selectionState.selection}
onClear={selection.clearSelection}
/>
)}
</>
);
}selection.selectionState updates when text is pinned, cleared, dismissed,
cancelled, or destroyed. Use it for app-rendered selected-text confirmation and
inline chat inputs. selection.getSelection() returns the same current pinned
text packet, selected range metadata, and affordance element for imperative
code.
Use onSelectionChange(state) to keep chat input state aligned with the pinned
text selection.
License
MIT
