Serve, observe and deploy
React and UI bindings
Show live agent runs, workflow progress and human response forms in React, or in any UI framework through headless stores.
When a web app calls agents through the Mayura server, it needs to show what is happening: the run's status, which tools are running, text as it streams, and forms for people to answer questions a workflow asks. Mayura splits this into two layers:
- Headless stores in
mayura/clientsubpaths. They hold state and send requests, with no UI code:mayura/client/headless(run state),mayura/client/forms(human response forms) andmayura/client/workflows(workflow graphs and commands). Any framework can use them throughsubscribeandgetSnapshot. - React bindings: hooks in
mayura/client-reactand unstyled components inmayura/client-react/components.
React 18.3 or 19 is an optional peer dependency. Install it yourself:
npm install mayura react react-domNothing here starts a request on its own. Mounting a component or calling a hook never fetches, streams, retries or cancels. Your event handlers call the store's methods when you decide to.
A live run in React
This submits a question, follows the run's events until it completes, and shows tool activity, streamed text and the final reply.
import { useEffect, useState } from 'react';
import { createClient } from 'mayura/client';
import { createHeadlessRunStore, type HeadlessRunStore } from 'mayura/client/headless';
import { useMayuraRun, useMayuraRunActivity } from 'mayura/client-react';
import { z } from 'mayura';
const Reply = z.object({ reply: z.string() });
const client = createClient({ baseUrl: `${window.location.origin}/`, token: () => sessionToken() });
function RunView({ store }: { readonly store: HeadlessRunStore }) {
const state = useMayuraRun(store);
const activity = useMayuraRunActivity(state);
return (
<section>
<p>{state.connection === 'reconnecting' ? 'Reconnecting…' : state.snapshot?.status ?? state.connection}</p>
<ul>
{activity.items.filter(item => item.kind === 'tool').map(item => <li key={item.id}>{item.label}: {item.status}</li>)}
</ul>
{state.streamedOutput && <p>{state.streamedOutput.text}</p>}
</section>
);
}
export function Ask() {
const [store, setStore] = useState<HeadlessRunStore | null>(null);
const [reply, setReply] = useState<string | null>(null);
useEffect(() => () => store?.dispose(), [store]);
async function ask(message: string): Promise<void> {
const run = await client.submit('support.assistant', { message }, { idempotencyKey: crypto.randomUUID() });
const next = createHeadlessRunStore({ run });
setStore(next);
setReply(null);
// Follows the run to its end, reconnecting by itself if the stream drops; throws on real failures.
await next.observe();
const outcome = await run.result(Reply);
setReply(outcome?.status === 'succeeded' ? outcome.output.reply : 'Something went wrong.');
}
return (
<div>
<button onClick={() => void ask('Where is my order?').catch(() => setReply('Something went wrong.'))}>Ask</button>
{store && <RunView store={store} />}
{reply && <p>{reply}</p>}
</div>
);
}The chat UI in the support-agent starter (see mayura init) is a complete version of this, with sign-in and styling.
The headless run store
createHeadlessRunStore({ run }) wraps one RemoteRun from the client. You own it: create it once per run and call
dispose() when the view goes away. Disposing stops observation; it does not cancel the run.
| Method | What it does |
|---|---|
refresh() |
Reads the run's current snapshot once. |
observe() |
Follows the run's events until run.completed, then reads the snapshot. The client reconnects dropped streams from the last sequence (see Streaming run events). One at a time. |
cancel() |
Asks the server to cancel the run. Never retried. |
subscribe(listener), getSnapshot() |
The external-store contract React and other frameworks use. |
dispose() |
Stops everything the store started and removes listeners. |
The state from getSnapshot() is immutable and changes identity on every update:
| Field | Meaning |
|---|---|
connection |
idle, loading, observing, reconnecting (the stream dropped and is being reopened; errorCode says why), stopped, error or disposed. |
snapshot |
The last run snapshot (status, budget, tool receipts), or null. |
events, lastSequence |
The latest events (up to maxEvents, default 256) and the last sequence seen. |
hasGap |
Some events were missed; treat the timeline as incomplete and trust snapshot. |
activity |
How many model, tool and hook calls are in progress (null after a gap). |
streamedOutput |
For agents that stream an output field: the text so far for the latest model call, plus withheld (a stream guard stopped it) and complete. The final output from run.result() is what counts. |
errorCode |
The last error code, safe to show or log: the server's own code (for example RUN_NOT_FOUND or AUTH_EXPIRED) when it sent one. |
createRunActivityProjection(state) (or the useMayuraRunActivity hook) turns the events into a timeline of run,
step, model, tool, hook and delegate items, each active, completed, failed, blocked, cancelled,
outcome_unknown or unknown. unknown means the events cannot tell; do not guess.
Hooks
| Hook | Returns |
|---|---|
useMayuraRun(store) |
The store's current state. |
useMayuraRunActions(store) |
Stable refresh, observe and cancel functions for event handlers. |
useMayuraRunActivity(state) |
The activity timeline for a state. |
useMayuraHumanRequest(request, nowMs) |
Display fields for a human request: statusText, actionText, urgency, canRespond. |
useMayuraHumanResponseCommand(controller) |
The state of a human response submission. |
useMayuraWorkflowGraph(view) |
A workflow run as a graph with progress counts. |
useMayuraWorkflowCommand(controller) |
The state of a workflow command. |
A hook given something that is not a Mayura store or controller throws MayuraReactError.
Components
mayura/client-react/components has accessible, unstyled components. They render plain semantic HTML (sections,
lists, forms, role="status" live regions) with data-mayura-component and data-status attributes to style from,
and render every value as text.
| Component | Shows |
|---|---|
MayuraRunSummary |
A run's status and activity timeline, from a store. |
MayuraWorkflowGraph |
A workflow run's steps, their status and dependencies, from a run view. |
MayuraHumanRequestCard |
A human request's prompt and status, with a respond button that calls onRespond. |
MayuraHumanResponseForm |
A typed form for answering a human request (below). |
MayuraWorkflowPauseControl |
A pause or resume button for one workflow run. |
MayuraFleetHoldControl |
Hold and release for the whole fleet, with a confirmation step before holding. |
Components never send anything themselves. Buttons and forms call your callbacks, and you send the command.
Human response forms
A durable workflow can stop and wait for a person to answer a typed question (see
Approvals and human input). Each request names a schemaId and schemaDigest. In
the browser you define a matching form, and the form validates the answer before anything is sent:
import { useEffect, useState } from 'react';
import type { MayuraClient, RemoteHumanRequest } from 'mayura/client';
import { createHumanResponseController, defineHumanResponseForm, type HumanResponseController } from 'mayura/client/forms';
import { useMayuraHumanResponseCommand } from 'mayura/client-react';
import { MayuraHumanResponseForm } from 'mayura/client-react/components';
const reviewForm = defineHumanResponseForm({
schemaId: 'refund-review',
schemaDigest: REFUND_REVIEW_DIGEST, // the same digest as the workflow's human step
fields: [
{ kind: 'select', name: 'decision', label: 'Decision', required: true, options: [
{ value: 'approve', label: 'Approve' },
{ value: 'reject', label: 'Reject' },
] },
{ kind: 'textarea', name: 'note', label: 'Note for the customer', maxLength: 2_000 },
],
});
function ReviewForm({ controller, request }: { readonly controller: HumanResponseController; readonly request: RemoteHumanRequest }) {
const command = useMayuraHumanResponseCommand(controller);
const [commandId] = useState(() => crypto.randomUUID()); // reuse it only to retry this same answer
return (
<MayuraHumanResponseForm
request={request}
definition={reviewForm}
nowMs={Date.now()}
commandState={command}
onSubmit={submission => { void controller.submit(submission, { commandId }).catch(() => undefined); }}
/>
);
}
export function Review({ client, request }: { readonly client: MayuraClient; readonly request: RemoteHumanRequest }) {
const [controller, setController] = useState<HumanResponseController | null>(null);
useEffect(() => {
const next = createHumanResponseController({ client, request });
setController(next);
return () => next.dispose();
}, [client, request]);
return controller && <ReviewForm key={request.digest} controller={controller} request={request} />;
}- Field kinds are
text,textarea,number,integer,booleanandselect, up to 32 fields. validateHumanResponse(request, definition, draft)is the same validation without React. The form calls it on submit and passes a checked submission, bound to the request's id and digest, toonSubmit.- A controller sends one answer at a time and never retries. Its state is
idle,submitting,succeeded,conflict(the request changed or was already answered: fetch it again) orfailed. - Create the controller only for a request whose
statusiswaiting. List waiting requests withclient.humanRequests(); the caller needshumans:readandhumans:respond.
Workflow progress and controls
client.workflow(runId) returns a run view. Show it with MayuraWorkflowGraph, and steer it through a command
controller, which runs one command at a time against that exact revision:
import { useEffect, useState } from 'react';
import type { MayuraClient } from 'mayura/client';
import { createWorkflowCommandController, type WorkflowCommandController, type WorkflowViewInput } from 'mayura/client/workflows';
import { useMayuraWorkflowCommand } from 'mayura/client-react';
import { MayuraWorkflowGraph, MayuraWorkflowPauseControl } from 'mayura/client-react/components';
function Controls({ controller, view, onChange }: {
readonly controller: WorkflowCommandController; readonly view: WorkflowViewInput; readonly onChange: (view: WorkflowViewInput) => void;
}) {
const command = useMayuraWorkflowCommand(controller);
const send = (action: 'pause' | 'resume') => {
void controller[action]({ commandId: crypto.randomUUID() }).then(onChange, () => undefined);
};
return (
<>
<MayuraWorkflowGraph input={view} />
<MayuraWorkflowPauseControl workflow={view} commandState={command} onPause={() => send('pause')} onResume={() => send('resume')} />
</>
);
}
export function WorkflowRun({ client, initial }: { readonly client: MayuraClient; readonly initial: WorkflowViewInput }) {
const [view, setView] = useState(initial);
const [controller, setController] = useState<WorkflowCommandController | null>(null);
useEffect(() => {
// One controller per revision: after a command applies, the new view gets a fresh controller.
const next = createWorkflowCommandController({ client, workflow: view });
setController(next);
return () => next.dispose();
}, [client, view]);
return controller && <Controls key={view.revision} controller={controller} view={view} onChange={setView} />;
}A command that finds the run changed (another operator, a fleet sweep) ends in conflict: read the run again and
decide. The thrown ClientError has the server's code (WORKFLOW_CONFLICT) and, when the token may read the run,
details.currentRevision. The caller needs workflows:read and workflows:control; see Workflow operations.
Other frameworks
The stores follow the common external-store shape, so Vue, Svelte, Solid or plain DOM code can use them directly:
import { createHeadlessRunStore } from 'mayura/client/headless';
const store = createHeadlessRunStore({ run: client.run(runId) });
const unsubscribe = store.subscribe(() => render(store.getSnapshot()));
await store.refresh();
void store.observe();
// When the view goes away:
unsubscribe();
store.dispose();createHumanRequestView(request, nowMs) in mayura/client/headless gives the same display fields as
useMayuraHumanRequest. Render prompts and ids with textContent, never as HTML.
Good to know
- Everything the browser sees is metadata or validated output: event streams carry no prompts, tool inputs or tool outputs, and the store rejects anything outside the expected shapes.
- Keep secrets out of the bundle. The browser holds a short-lived token that your backend issues; the server decides what that token may do.
streamedOutputis provisional. A guard can withhold the rest of the stream, and the validated result can differ.