Structured data collection
LiveLayer agents can run guided, typed conversations that collect a defined set of fields and deliver a structured payload to your host page or a webhook. The whole system is built on LiveKit's voice.AgentTask + beta.workflows.TaskGroup primitives — same pattern LiveKit Agent Builder uses for structured data collection.
The headline API: write regular HTML forms. No wrapper components, no special attributes. The agent auto-discovers every <form> on the page and surfaces it.
Three places fields can come from
| Source | Where you declare them | When it fires |
|---|---|---|
Page form (source: "page") | A <form> on your page. Auto-discovered. | LLM picks collect_from_page when the visitor wants to fill the form by voice. |
Agent-declared (source: "agent") | Dashboard → agent → Behavior → Data collection panel. | LLM picks start_data_collection when the visitor signals readiness ("sign me up", "book a demo"). |
Slide form (source: "slide") | Slide editor → slide inspector → Data collection toggle. | Fires when the agent advances to a slide that has the toggle on. |
All three paths produce the same CollectedResult shape.
The 30-second integration
React
import { AvatarWidget } from "@livelayer/react";
export default function Page() {
return (
<>
<AvatarWidget
agentId="agent_abc"
onCollect={(result) => {
fetch("/api/leads", {
method: "POST",
body: JSON.stringify(result),
});
}}
/>
{/* Plain HTML — the agent finds this. */}
<form>
<label>Email <input name="email" type="email" required /></label>
<label>Company <input name="company" /></label>
<label>Notes <textarea name="notes" /></label>
<button type="submit">Subscribe</button>
</form>
</>
);
}
That's the whole integration. The agent walks the DOM, finds the form, infers each field's label from <label> / aria-label / placeholder, infers the kind from type=, and runs a TaskGroup when the visitor asks for help filling it. Values paint into the inputs live as the agent records each one — no value= plumbing needed. When the run finishes, onCollect fires with the typed payload.
Script-tag
<script src="https://livelayer.studio/v1.js" data-agent-id="agent_abc"></script>
<form>
<label>Email <input name="email" type="email" /></label>
<label>Company <input name="company" /></label>
</form>
<script>
document.querySelector("livelayer-widget").addEventListener(
"ll-collected",
(e) => {
// Two phases: "field" (mid-flow) and "complete" (final).
if (e.detail.phase !== "complete") return;
console.log("collected:", e.detail.result.results);
},
);
</script>
Live painting works identically — the SDK writes into matching [name="..."] inputs and dispatches native input + change events so any framework's two-way binding stays in sync.
Opt-out, not opt-in
Every <form> is visible to the agent by default. You opt OUT when you don't want the agent to see something:
<!-- Exclude a whole form -->
<form data-ll-skip>...</form>
<!-- Exclude a single input -->
<input data-ll-private />
<!-- Always-excluded (browser-native signals) -->
<input type="password" />
<input autocomplete="cc-number" />
<input autocomplete="cc-csc" />
<input autocomplete="off" />
These guards are unconditional — even if a customer accidentally tags a sensitive field with a regular name=, the agent still can't touch it.
Disambiguation when a page has many forms
The agent will usually figure out which form is the right one from surrounding text (headings, the submit button's label, aria-label on the form). If it picks wrong, give it a hint:
<form data-ll-intent="request a demo">...</form>
<form data-ll-intent="newsletter signup">...</form>
data-ll-intent is the only hint you'd ever realistically need.
Streaming updates (advanced)
For a live progress sidebar or per-field UX, subscribe with useCollect():
import { useCollect } from "@livelayer/react";
function Progress() {
const { fields, isCollecting, lastResult } = useCollect();
if (!isCollecting && !lastResult) return null;
return (
<aside>
<h4>Collected so far</h4>
<ul>
{Object.entries(fields).map(([name, value]) => (
<li key={name}><strong>{name}:</strong> {value}</li>
))}
</ul>
</aside>
);
}
Restrict to one source with useCollect({ source: "page" }). Use onFieldUpdate / onComplete callbacks if you'd rather imperative-handle than read state.
The result shape
interface CollectedResult {
sessionId: string; // LiveKit room name
startedAt: string; // ISO timestamp
endedAt: string; // ISO timestamp
source: "page" | "agent" | "slide";
formId?: string; // present when source = "page"
slideId?: string; // present when source = "slide"
results: Record<string, {
fieldId: string;
fieldName: string; // the key in `results`
value: string; // normalized; coerce on your side
kind: string; // "email" | "phone" | "number" | etc.
}>;
summary?: string; // LLM-generated 1–2 sentence summary
}
Mirrors the LiveKit Agent Builder result shape so the same backend handler works whether the agent was authored in LiveKit's UI or in LiveLayer.
Webhooks (no client code)
For server-side delivery, set the Webhook URL in the agent's Data collection panel. The worker POSTs the same payload that's dispatched to the client — useful when you'd rather route results into a CRM without writing browser code.
Capabilities gate
collect_data gates the entire feature. If you pass an explicit capabilities allowlist to <AvatarWidget>, add it:
<AvatarWidget
agentId="..."
capabilities={["navigate", "fill_forms", "collect_data"]}
/>
Default (no capabilities prop) permits everything — most clients never set this.
What the agent sees, in plain English
extractPageContextwalks the DOM, finds every<form>, surfaces each field'sname,label(best-effort inferred),type,required, and any<select>options. The whole snapshot is capped at 4 KB.- When the visitor says something form-shaped ("yeah let's sign me up"), the LLM picks
collect_from_page(form_id, fields)and hands control to a TaskGroup. - The TaskGroup walks the fields in order. Each field runs a
voice.AgentTask<string>whose instructions carry per-kind normalization rules (email letter-by-letter, phone digit grouping, ISO date format, currency stripping, etc). - Each recorded value is published over the data channel; the SDK paints into the matching
<input name="...">and the host page sees onell-collectedevent per field. - On completion, the final payload is published with
phase: "complete". The host page'sonCollecthandler (or itsll-collectedlistener) fires once.
You don't have to think about any of this — the toggle is the API. But the primitives are exported from agent/lib/tasks/ for self-hosted deployments and custom task subclasses.