@flashcatcloud/ai-kit
v0.5.0
Published
Headless runtime primitives shared by Flashcat AI surfaces. Entry /actions: UI action registry, executor, policy gate and visual feedback.
Readme
@flashcatcloud/ai-kit
Headless runtime primitives shared by Flashcat's AI surfaces.
The package deliberately contains no UI. Chat rendering, layout, session management and transport stay with each host — this is only the piece all of them would otherwise write again.
@flashcatcloud/ai-kit/actions
Turns a model-issued tool call into a validated, policy-gated, time-bounded call of host code, and turns the outcome back into a payload the model can reason about.
A host wires two lines:
import { createActionRuntime, createDomFeedback } from '@flashcatcloud/ai-kit/actions';
const runtime = createActionRuntime({
feedback: createDomFeedback(),
confirm: (req) => showConfirmDialog(req), // required for policy: 'confirm'
onAudit: (e) => log('ui-action', e),
});
// 1. the event stream sees a tool call
if (runtime.shouldHandle(event.name)) {
const response = await runtime.execute({
callId: event.callId,
name: event.name,
args: event.input,
});
// 2. hand it back over the host's existing function_response channel
sendMessage(null, { functionResponse: { call_id: event.callId, name: event.name, response } });
}plus runtime.manifest() on the outgoing run request, so the backend knows
which actions exist for this turn.
Pages register what they can do while they are mounted:
useEffect(() => runtime.register([
{
name: 'set_integration_field',
description: 'Fill one field of the "new integration" form.',
inputSchema: { type: 'object', properties: { field: { type: 'string', enum: ['name'] }, value: { type: 'string' } }, required: ['field', 'value'] },
run: async ({ field, value }, ctx) => {
await ctx.feedback.moveCursor(`#field-${field}`);
ctx.feedback.highlight(`#field-${field}`);
form.setFieldValue(field, value);
// Read back what the form actually holds — see "What run() returns".
return { field, value: form.getFieldValue(field) };
},
},
]), []);Registration is scoped on purpose: when the page unmounts, the action goes
away, and a call that arrives afterwards comes back as unsupported with a
written fallback instead of firing at a page that is no longer there.
One page, several bundles
createActionRuntime gives each caller its own registry, which is right when
one host owns the page. It stops being right when a page is assembled from
parts that were bundled separately: the part that registers an action and the
part whose chat surface executes it each carry their own module state, so they
end up with two registries that never see each other — and every call comes
back unsupported with nothing wrong in either half.
getSharedActionRuntime is the same runtime pinned to globalThis under a
registered symbol, so every bundle on the page resolves to one instance:
import { getSharedActionRuntime, createDomFeedback } from '@flashcatcloud/ai-kit/actions';
// Each host asks the same way. The first call creates the runtime with these
// defaults; every later call, from any bundle, gets that same instance.
export const runtime = getSharedActionRuntime({ feedback: createDomFeedback() });The argument is defaults, and means it: options only take effect on the call
that creates the runtime. A later call cannot reconfigure a registry other
code is already registered on, and its onStep/onAudit observers never
fire — passing any options to a later call is warned about for that reason.
Prefer createActionRuntime whenever one host owns the whole page.
Two things the word "shared" does not cover. The instance lives on the
page's globalThis, so a worker or an iframe is a different realm with its
own instance — the pin does not cross that boundary. And the key carries the
release's compatibility range, so a copy of this library whose ActionRuntime
may differ (another 0.x minor, or another major from 1.0) gets its own
instance rather than someone else's object: that is deliberately the same
outcome as not sharing at all. Call it in browser code only; at module scope
in something server-rendered it would pin to the server process instead.
resetSharedActionRuntime() drops the pin so the next call starts fresh —
for test isolation across files, mainly. Instances already handed out keep
working.
What run() returns
Whatever run() resolves to is handed to the model verbatim as result, and
it is the model's only evidence about the outcome. Return state read back
after the change — the field's current value, the id the server assigned,
the row count that came back — not the arguments echoed.
// ✗ The model can only repeat its own intention.
run: ({ field, value }) => { form.setFieldValue(field, value); return { field, value }; }
// ✓ The model can say what is actually true now.
run: ({ field, value }) => { form.setFieldValue(field, value); return { field, value: form.getFieldValue(field) }; }The difference only shows up in the case that matters: when something downstream clamped, normalized or ignored the value, and the field does not say what the model asked for. An echoed argument reports success there; a read-back reports the truth.
Asking the user
policy: 'confirm' gates an action behind the host's confirm handler, and
fails closed: an action that declares it on a runtime with no handler wired is
refused rather than run unattended.
By default the question is asked on every call. For the repetitive steps of
one task, confirmScope: 'turn' asks once and lets the rest of that turn
through:
{ name: 'set_field', policy: 'confirm', confirmScope: 'turn', /* … */ }
// five fields, one question — instead of interrupting the user five timesA turn ends when the host next calls manifest(), which it already does on
every outgoing run request. There is no turn object to open and, more to the
point, none to forget to close — an approval cannot outlive the turn by
accident. A decline is never remembered, so saying no once does not become a
standing answer either way.
Widen the scope for filling and selecting. Leave it at 'call' for anything
the user would want to be asked about every single time — which is most of
what spends money or deletes data.
Two things follow from the boundary being manifest(), and they are the price
of not having a lifecycle to manage:
- Call
manifest()once per outgoing run request, and not for anything else. A host that caches the declarations and stops calling it leaves turn-scoped approvals standing for as long as the tab is open; a host that calls it to populate a debug panel ends them early. The first of those errs in the unsafe direction, which is why'turn'is opt-in per action rather than a runtime-wide setting. - An approval never crosses a boundary in either direction: one granted after its own turn has ended is discarded, and one granted before a page unmounts goes with it, so a remounted action object cannot inherit it.
Two parallel calls of the same action can both reach the gate before either is answered, and the user is asked twice. It fails closed, so this is redundancy rather than a hole, and collapsing it would mean holding one call's answer against a call the user has not been shown.
Telling the user what is happening
An action that runs invisibly is indistinguishable from one that did not run. Three surfaces cover that, and they are independent — take any of them alone.
Per-step visuals. ctx.feedback reveals the target, rings it, glides a
virtual cursor onto it and plays a press. createDomFeedback() draws them;
noopFeedback() is the default. controlledRegion frames the whole area the
assistant is working inside, raised for the duration of an action and not a
moment longer.
A session banner. With controlledRegion set, createDomFeedback can also
put a strip at the top of the frame saying what is happening, with a button
that stops the call in flight. Off unless you pass the words — they are the
user's language, not the runtime's:
createActionRuntime({
controlledRegion: () => document.body,
feedback: createDomFeedback({
accent: '108,77,245', // r,g,b — your product's colour
banner: () => ({ acting: t('ai.acting'), done: t('ai.done'), cancel: t('ai.stop') }),
}),
});Stopping aborts the call's signal; an action that honours it comes back as
declined with a message saying the user stopped it, and one that ignores it
finishes as it would have. The banner is re-worded as done when the call
settles and comes down with the rest of the dwell.
A live step log. onStep fires synchronously as each step begins — after
every gate has passed, before run() is called — so the host can put the step
on screen while it is happening rather than after:
const runtime = createActionRuntime({
onStep: ({ action, args, label }) => appendStepToChat(label), // label, or your own line from action + args
onAudit: (e) => log('ui-action', e), // settled outcome, for the record
});onStep and onAudit are the two halves of one turn: onStep says "this is
happening", onAudit says "this is what happened". A step is announced only if
it actually ran, so a declined or malformed call never appears in the log.
A tab-title marker. For a user who has tabbed away — and above all for one the assistant is silently waiting on:
createActionRuntime({ titleStatus: { acting: '[AI working]', awaitingUser: '[Needs you]' } });Off unless you pass labels; there is no language the runtime could default to that is right for every console. The original title is captured when the first mark goes up and restored exactly when the last one comes down.
Why registered actions and not DOM driving
These are our own React applications, so an action can call the same business API the user's own click would. Server-side RBAC therefore applies to the AI exactly as it applies to the user — which a simulated click cannot offer. It is also cheaper: no page snapshot per step, and a UI redesign does not break an action whose signature is unchanged.
Development
npm install
npm test # vitest, jsdom
npm run build # tsc → dist/