Skip to Content
DocumentationGuidesForm Tracking

Overview

Forms in Oshara are not fire-and-forget. The widget and the agent stay in sync throughout the entire fill flow via the LiveKit data channel. This lets the agent:

  • Know exactly which form is open and which step the user is on
  • Read every field value as the user types
  • Verbally guide the user (“It looks like your email is incomplete”)
  • Know when the user submits and continue the conversation naturally

This page explains every JSON payload involved, from the moment the agent opens a form to the moment your backend receives the submission.


Full lifecycle

Agent calls tool → Widget opens form → User fills fields → Widget publishes state (250ms) Agent reads state ◄─────┘ Agent speaks guidance User clicks Submit ────────────┘ Widget POSTs to submit_url Widget publishes confirmation Agent receives confirmation ──► continues conversation

Phase 1 — Agent opens the form

The LLM decides to open a form and calls the tool named after the form’s id. Hyphens in the id become underscores in the tool name (e.g. book-demo → tool book_demo).

Tool call from the LLM:

{ "name": "book_demo", "arguments": { "name": "Alice Smith", "email": "alice@acme.com" } }

The worker publishes this as a LiveKit data message on topic form.book-demo:

{ "name": "Alice Smith", "email": "alice@acme.com" }

The widget receives it, opens the form panel, and pre-fills the name and email fields immediately. Any argument key that matches a field name is pre-filled; the rest are blank.


Phase 2 — Widget publishes form state (continuous)

Once the form is open, the widget publishes the current state every ~250 ms on topic form.state. The agent worker subscribes to this topic and receives it as a tool result update.

Topic: form.state

Single-page form state

{ "type": "form_state", "form_id": "book-demo", "is_open": true, "step_index": 0, "total_steps": 1, "values": { "name": "Alice Smith", "email": "alice@", "company": "", "date": "" }, "fields": [ { "name": "name", "label": "Your name", "type": "text", "required": true }, { "name": "email", "label": "Work email", "type": "email", "required": true }, { "name": "company", "label": "Company", "type": "text", "required": false }, { "name": "date", "label": "Preferred date","type": "date", "required": true } ] }

Multi-step form state

When the user advances to step 2 of a 3-step form:

{ "type": "form_state", "form_id": "onboarding", "is_open": true, "step_index": 1, "total_steps": 3, "values": { "use_case": "Customer support", "team_size": "" }, "fields": [ { "name": "use_case", "label": "What are you building?", "type": "select", "required": true, "options": ["Customer support", "Sales assistant", "Internal tool", "Other"] }, { "name": "team_size", "label": "Team size", "type": "number", "required": false } ] }

fields always contains only the fields for the current step — not the full form. This is what the agent uses to understand what the user is looking at right now.

State payload reference

FieldTypeDescription
type"form_state"Always "form_state".
form_idstringThe id from your form definition (e.g. "book-demo").
is_openbooleantrue while the form panel is visible. false if the user closed it.
step_indexnumberCurrent step index (0-based). Always 0 for single-page forms.
total_stepsnumberTotal number of steps. 1 for single-page forms.
valuesobjectCurrent field values keyed by field name. Empty string = not yet filled.
fieldsarraySchema of the fields on the current step — label, type, required, options, etc.

Phase 3 — Agent reads state and guides the user

The agent worker feeds the form.state messages into the LLM’s context. The LLM can use this to provide real-time verbal guidance. Example system prompt instructions:

While a form is open, read the form_state messages to understand what the user has filled in. If a required field is empty or has an invalid value, gently prompt the user verbally. Do not repeat guidance for the same field more than once. When step_index changes, acknowledge the new step briefly ("Great — now let's pick a date that works for you"). When is_open becomes false before submission, ask if the user wants to continue the booking.

The LLM receives state messages passively — it can speak at any time, and the state gives it the context to do so usefully.


Phase 4 — Form submission

When the user clicks the submit button, the widget:

  1. Runs client-side validation (required fields, email format, patterns)
  2. POSTs to submit_url if validation passes
  3. Publishes either a success confirmation or a failure message on the data channel

Submit POST (to your backend or Oshara)

Request — sent by the widget to your submit_url:

POST https://yoursite.com/api/demo-requests Content-Type: application/json { "name": "Alice Smith", "email": "alice@acme.com", "company": "Acme Inc", "date": "2025-07-01" }

The body is a flat key-value object — field name → field value. No metadata wrapper.

Your endpoint must return 2xx to signal success to the widget.

If submit_url is null (managed storage)

The widget POSTs to Oshara’s internal form-response endpoint instead:

POST /api/agents/{slug}/form-responses/ { "form_id": "book-demo", "session_id": "sess_a1b2c3", "values": { "name": "Alice Smith", "email": "alice@acme.com", "date": "2025-07-01" } }

Phase 5 — Submission confirmation (data channel)

After a successful submit, the widget publishes a confirmation message so the agent knows the form is done.

Topic: confirmation_topic from the form definition (default: "voice.user_text")

Payload:

{ "type": "book-demo_submitted", "form_id": "book-demo", "text": "I have confirmed the form submission.", "form": { "name": "Alice Smith", "email": "alice@acme.com", "company": "Acme Inc", "date": "2025-07-01" } }
FieldDescription
type"{form_id}_submitted" by default. Override with confirmation_type in the form definition.
form_idThe form’s id.
textHuman-readable summary — fed into the LLM as if the user said it.
formComplete submitted values. The agent can reference these in its response.

The LLM receives this and naturally continues the conversation — e.g. “Perfect! I’ve got your details. Someone from our team will reach out to alice@acme.com before July 1st.”


Phase 5 (error) — Submission failure

If your endpoint returns non-2xx, or the network fails, the widget publishes a failure message instead:

Topic: form.state

Payload:

{ "type": "form_submit_failed", "form_id": "book-demo", "text": "The form submission failed. Please try again or continue via voice." }

The agent receives this and can offer alternatives — retry, collect the information verbally, or escalate.


Phase 5 — Form closed without submitting

If the user closes the form panel without submitting, the widget sends a final form.state with is_open: false:

{ "type": "form_state", "form_id": "book-demo", "is_open": false, "step_index": 1, "total_steps": 3, "values": { "name": "Alice", "email": "", "company": "", "date": "" }, "fields": [...] }

The agent can detect is_open: false without a subsequent _submitted message and react accordingly.


Retrieving submissions via API

From your own endpoint

Your submit_url endpoint receives every submission directly — no polling needed.

From Oshara managed storage (submit_url: null)

# All submissions for a form GET /api/agents/support-bot/form-responses/?form_id=book-demo Authorization: Bearer <token>
[ { "id": 1, "form_id": "book-demo", "session_id": "sess_a1b2c3", "values": { "name": "Alice Smith", "email": "alice@acme.com", "company": "Acme Inc", "date": "2025-07-01" }, "created_at": "2025-06-10T15:42:00Z" }, { "id": 2, "form_id": "book-demo", "session_id": "sess_d4e5f6", "values": { "name": "Bob Jones", "email": "bob@widgets.com", "company": "Widgets Co", "date": "2025-07-03" }, "created_at": "2025-06-11T09:15:00Z" } ]
# Single session — all forms submitted during one call GET /api/agents/support-bot/form-responses/?session_id=sess_a1b2c3 Authorization: Bearer <token>
# Combined — specific form + session GET /api/agents/support-bot/form-responses/?form_id=book-demo&session_id=sess_a1b2c3 Authorization: Bearer <token>

Customising the confirmation behaviour

Custom confirmation type

By default the type field is "{form_id}_submitted". Override it if your backend or agent prompt keys off a specific event type:

{ "id": "book-demo", "confirmation_type": "demo_booking_confirmed" }

Confirmation payload becomes:

{ "type": "demo_booking_confirmed", "form_id": "book-demo", ... }

Custom confirmation topic

By default confirmations are published on "voice.user_text" (treated as user speech by the agent). To separate form confirmations from user speech:

{ "id": "book-demo", "confirmation_topic": "form.confirmed" }

Building a custom form consumer (raw LiveKit)

If you’re building your own front-end (not using the Oshara widget), subscribe to the same topics from the LiveKit room:

import { Room, RoomEvent } from "livekit-client"; room.on(RoomEvent.DataReceived, (payload, participant, kind, topic) => { const msg = JSON.parse(new TextDecoder().decode(payload)); switch (topic) { case "form.book-demo": // Agent wants to open the book-demo form openFormUI("book-demo", msg); // msg = pre-fill values break; case "voice.agent_handoff": showHandoffScreen(msg.agent_name); break; } }); // After user submits your form, publish confirmation back to the agent: async function onFormSubmit(formId, values) { const confirmation = JSON.stringify({ type: `${formId}_submitted`, form_id: formId, text: "I have confirmed the form submission.", form: values }); await room.localParticipant.publishData( new TextEncoder().encode(confirmation), { topic: "voice.user_text" } ); } // Publish state updates while the form is open: function publishFormState(formId, stepIndex, totalSteps, values, fields) { const state = JSON.stringify({ type: "form_state", form_id: formId, is_open: true, step_index: stepIndex, total_steps: totalSteps, values, fields }); room.localParticipant.publishData( new TextEncoder().encode(state), { topic: "form.state" } ); }

TopicDirectionWhenPayload type
form.{form-id}Agent → WidgetAgent opens the formPre-fill values object
form.stateWidget → AgentEvery ~250ms while open; once on closeform_state
form.stateWidget → AgentSubmit failureform_submit_failed
voice.user_text (default)Widget → AgentAfter successful submit{form_id}_submitted confirmation
voice.agent_handoffAgent → WidgetMid-call agent transfer{ agent_name }
Last updated on