View as markdown

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

SourceWhere you declare themWhen 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 → BehaviorData 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

tsx
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

html
<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:

html
<!-- 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:

html
<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():

tsx
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

ts
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:

tsx
<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

  1. extractPageContext walks the DOM, finds every <form>, surfaces each field's name, label (best-effort inferred), type, required, and any <select> options. The whole snapshot is capped at 4 KB.
  2. 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.
  3. 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).
  4. Each recorded value is published over the data channel; the SDK paints into the matching <input name="..."> and the host page sees one ll-collected event per field.
  5. On completion, the final payload is published with phase: "complete". The host page's onCollect handler (or its ll-collected listener) 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.