Consensus views
When a gated tool call pauses, DioscHub opens the consensus dialog and builds the detail form from the tool’s input schema — one widget per argument. That form is generic by construction. It knows a field is a string; it does not know the string is a ticket status, a shipping route, or a diff against a diagram.
A consensus view replaces that one region with a component you write.
What you take over, and what you don’t
Section titled “What you take over, and what you don’t”A consensus view replaces the detail body only. DioscHub keeps the master list, the banners, the reason box, approve and reject, the batch footer — and, most importantly, the decision itself.
There is no way to approve or reject from host code, at any tier. That is deliberate: an approval is only meaningful if the user acted on a surface DioscHub controls, and a host-rendered approval would be indistinguishable from a bug or an injected script auto-resolving it.
Register a view
Section titled “Register a view”Give DioscHub a tool matcher and three lifecycle functions.
const unregister = diosc('consensusView', /_ticket$/, { mount(el, ctx) { // `el` is an empty element. Render into it. }, update(ctx) { // The operator edited a field, or switched to another request. }, unmount() { // Clean up. `ctx.signal` is already aborted. },});
// Hand the tool back to the built-in form:unregister();A string matches one tool by exact name; a RegExp matches the prefixed runtime tool name such as
acme-helpdesk_update_ticket. Exact matches win over patterns. A tool with no matching view renders
the built-in schema form, so you can take over one tool and leave the rest alone.
What you receive
Section titled “What you receive”ctx is created once per request and stays the same object for that request’s lifetime. DioscHub
mutates it in place and then calls update(ctx), so a reactive host can hold onto it directly.
interface ConsensusViewCtx { tool: string; op: 'create' | 'update' | 'delete'; requestId: string; rowId: string; // "TK-1041", or "NEW" for a create title: string;
request: Record<string, unknown>; // the model's proposed arguments previous?: Record<string, unknown>; // current state, when a companion tool read it companion?: string;
fields: ConsensusField[]; // DioscHub's diff — render from this
readOnly: boolean; // true for delete: a confirmation, not a form flagged?: boolean; flagReason?: string;
// Present when a previous edit was rejected by server-side schema validation. validationError?: { errors: Array<{ path: string; message: string }>; attempt: number };
edits: Record<string, unknown>; setField(key: string, value: unknown): void; resetEdits(): void;
signal: AbortSignal; // aborted on unmount}Each entry in fields carries the field’s schema metadata, its baseline and proposed values, and how
it changed:
interface ConsensusField { key: string; meta: { key: string; title: string; type: string; widget: string; /* enum, unit, format… */ }; from: unknown; to: unknown; kind: 'same' | 'changed' | 'added' | 'removed' | 'create'; readOnly: boolean;}previous is absent for a create, and for an update where no companion tool supplied current state.
In that case every field arrives with kind: 'create' and the body should read as a plain proposal.
If the operator approves with edits that fail the tool’s schema, DioscHub does not run the call. It
re-opens the same request with validationError set, so they can fix it — your view keeps its
state, and ctx.validationError carries the failures and a 1-based attempt. Surface it, or the
operator will see the dialog reappear with no explanation. The dialog also shows the failures in its
own banner above your body, so a view that ignores the field still degrades sensibly.
After two rejected attempts DioscHub stops re-prompting and refuses the call, reporting the failure to the assistant instead.
Auth headers, session tokens and credentials never reach an approval request, so none of them appear here. There is nothing to filter.
Reporting edits
Section titled “Reporting edits”Call ctx.setField(key, value) when the operator changes something. That writes into DioscHub’s own
edit ledger, which means the rest of the dialog keeps working with no extra effort from you:
- the master-list row gains an edited chip and updates its change summary
- a Reset N edited fields control appears
- the approve button relabels to Approve with changes
- on submit, the request is sent as an edit carrying
modifiedArgs
Setting a field back to its original value removes the override again.
Where your component is mounted
Section titled “Where your component is mounted”The el you receive is not inside the widget’s shadow DOM. It is a plain element in your page’s
light DOM, which DioscHub projects into the dialog. Two consequences:
- Your stylesheets apply. Tailwind, your design system, and the styles your framework injects into
document.headall reach the body. There is nothing to import into the widget. - Portals work. Render into
elfrom inside your existing component tree — ReactcreatePortal, Vue<Teleport>, Angular’sDomPortalOutlet, Solid’s<Portal>— and your providers, contexts and events keep working, because a portal preserves the framework tree rather than the DOM tree.
DioscHub creates one anchor per request, lazily, and keeps it alive while the dialog is open, so switching between parallel requests never destroys your component’s local state.
Framework recipes
Section titled “Framework recipes”import { useEffect, useRef, useState, useContext } from 'react';import { createPortal } from 'react-dom';
const TICKET_TOOLS = /_ticket$/; // module scope — a RegExp literal re-registers every render
function useConsensusView(pattern) { const [slot, setSlot] = useState(null); // StrictMode double-invokes effects in development (mount → cleanup → mount). // The guard stops a torn-down pass from writing state over the live one. const alive = useRef(true);
useEffect(() => { alive.current = true; const off = diosc('consensusView', pattern, { mount: (el, ctx) => alive.current && setSlot({ el, ctx, version: 0 }), // `ctx` identity is stable by design, so bump a version to re-render. update: (ctx) => alive.current && setSlot((s) => (s ? { el: s.el, ctx, version: s.version + 1 } : s)), unmount: () => alive.current && setSlot(null), }); return () => { alive.current = false; setSlot(null); off?.(); }; }, [pattern]);
return slot;}
function TicketBody({ ctx }) { const theme = useContext(ThemeContext); // resolves — the portal keeps your tree return ( <div className="ticket-consensus"> {ctx.fields.map((f) => ( <label key={f.key} data-kind={f.kind}> {f.meta.title} {f.kind === 'changed' && <s>{String(f.from)}</s>} <input value={f.to == null ? '' : String(f.to)} disabled={ctx.readOnly} onChange={(e) => ctx.setField(f.key, e.target.value)} /> </label> ))} </div> );}
// Anywhere inside your app tree — providers above it still apply to the body.function ConsensusSlot() { const slot = useConsensusView(TICKET_TOOLS); if (!slot) return null; return createPortal( <TicketBody ctx={slot.ctx} key={slot.version} />, slot.el, slot.ctx.requestId, );}<script setup>import { shallowRef, onMounted, Teleport } from 'vue';
const target = shallowRef(null);const ctx = shallowRef(null);const tick = shallowRef(0);
onMounted(() => { diosc('consensusView', /_ticket$/, { mount: (el, c) => { target.value = el; ctx.value = c; tick.value++; }, update: (c) => { ctx.value = c; tick.value++; }, unmount: () => { target.value = null; ctx.value = null; }, });});</script>
<template> <Teleport v-if="target" :to="target"> <TicketBody :key="tick" :ctx="ctx" /> </Teleport></template>inject() inside TicketBody resolves values from app.provide(), because a <Teleport> keeps the
application context.
Angular needs its own injector and change detection, so pass both — the component ends up attached to
your ApplicationRef while its DOM sits in the dialog.
export class ConsensusHost { private envInjector = inject(EnvironmentInjector); private appRef = inject(ApplicationRef); private ref: ComponentRef<TicketBodyComponent> | null = null;
constructor() { diosc('consensusView', /_ticket$/, { mount: (el, ctx) => { this.ref = createComponent(TicketBodyComponent, { environmentInjector: this.envInjector, // your DI, not a new root hostElement: el, }); this.ref.setInput('ctx', ctx); this.appRef.attachView(this.ref.hostView); // your change-detection tree this.ref.changeDetectorRef.detectChanges(); }, update: (ctx) => { this.ref?.setInput('ctx', ctx); this.ref?.changeDetectorRef.detectChanges(); }, unmount: () => { if (!this.ref) return; this.appRef.detachView(this.ref.hostView); this.ref.destroy(); this.ref = null; }, }); }}inject(SOME_TOKEN) inside TicketBodyComponent resolves against your root injector. Detaching the
view on unmount is required, or the view leaks.
import { mount, unmount } from 'svelte';import TicketBody from './TicketBody.svelte';import { hostUser } from './stores.js'; // module-level stores work unchanged
let app = null;
diosc('consensusView', /_ticket$/, { mount: (el, ctx) => { app = mount(TicketBody, { target: el, props: { ctx }, context: new Map([['acme-user', get(hostUser)]]), // see note }); }, update: (ctx) => { /* update your $state-backed props */ }, unmount: () => { if (app) { unmount(app); app = null; } },});Svelte is the one case where setContext/getContext does not carry across on its own — a mount
root starts a fresh context tree. Pass what you need through mount({ context }). Module-level stores
are unaffected and are usually the simpler answer.
import { createSignal } from 'solid-js';import { Portal } from 'solid-js/web';
function ConsensusSlot() { const [slot, setSlot] = createSignal(null);
diosc('consensusView', /_ticket$/, { mount: (el, ctx) => setSlot({ el, ctx }), update: (ctx) => setSlot((s) => (s ? { el: s.el, ctx } : s)), unmount: () => setSlot(null), });
return ( <> {slot() && ( <Portal mount={slot().el}> <TicketBody ctx={slot().ctx} /> </Portal> )} </> );}useContext inside TicketBody resolves through the owner chain, which <Portal> preserves.
Limits
Section titled “Limits”- One view per request. Rendering a whole batch of parallel requests as a single visual (a map with several pins, say) is not supported; each request gets its own body.
- The master-list row and the detail title stay DioscHub’s. They are generated from the tool name and arguments and cannot currently be overridden.
- No decision channel, by design — see above.
- Experimental. The context shape may change before 1.0. It is versioned with the assistant kit, so pin your kit version if you depend on it.