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 conversationPhase 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
| Field | Type | Description |
|---|---|---|
type | "form_state" | Always "form_state". |
form_id | string | The id from your form definition (e.g. "book-demo"). |
is_open | boolean | true while the form panel is visible. false if the user closed it. |
step_index | number | Current step index (0-based). Always 0 for single-page forms. |
total_steps | number | Total number of steps. 1 for single-page forms. |
values | object | Current field values keyed by field name. Empty string = not yet filled. |
fields | array | Schema 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:
- Runs client-side validation (required fields, email format, patterns)
- POSTs to
submit_urlif validation passes - 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"
}
}| Field | Description |
|---|---|
type | "{form_id}_submitted" by default. Override with confirmation_type in the form definition. |
form_id | The form’s id. |
text | Human-readable summary — fed into the LLM as if the user said it. |
form | Complete 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" }
);
}Summary of all form-related topics
| Topic | Direction | When | Payload type |
|---|---|---|---|
form.{form-id} | Agent → Widget | Agent opens the form | Pre-fill values object |
form.state | Widget → Agent | Every ~250ms while open; once on close | form_state |
form.state | Widget → Agent | Submit failure | form_submit_failed |
voice.user_text (default) | Widget → Agent | After successful submit | {form_id}_submitted confirmation |
voice.agent_handoff | Agent → Widget | Mid-call agent transfer | { agent_name } |