Forms#
A form is one intent. Put onSubmit={...} on the <form> and the submitted fields become the intent's input — validated against its schema, guarded like any other action, and callable by an agent without a form at all.
export const Signup = component({
name: 'signup',
description: 'Newsletter signup',
state: schema({ status: enums(['idle', 'sent', 'failed']).default('idle') }),
intents: {
submit: intent({
description: 'Subscribe an email address',
input: schema({ email: str().min(3), plan: enums(['free', 'pro']) }),
run: async ({ state, input }) => {
state.status = (await subscribe(input)) ? 'sent' : 'failed';
},
}),
},
view: ({ state, intents }) => (
<form onSubmit={intents.submit}>
<input name="email" type="email" required />
<select name="plan">
<option value="free">Free</option>
<option value="pro">Pro</option>
</select>
<button type="submit">Subscribe</button>
{state.status === 'sent' ? <p>Check your inbox.</p> : null}
</form>
),
});How the input is built#
On submit, Janux calls new FormData(form) and turns the entries into the intent's input object. Consequences worth knowing:
nameis what matters. A control without anameis not submitted — noname, no field, and the intent's schema validation will tell you so.- Everything arrives as a string (or a
File) — and schema types do not coerce.int()rejects"42",bool()rejects"on". This is deliberate: the same schema validates agent calls, where a real number is expected. See the pattern below. - The default submit is prevented for you — no full page reload, no
event.preventDefault()to remember. - Unchecked checkboxes are absent, not
false. Model them asbool().default(false)so the default fills the gap. - Same-named controls collapse to the last value (
FormData.entries()semantics). For multi-select, read the values inrunfrom your own state instead.
Numbers and checkboxes: declare what the form sends#
Take the string, convert in run. The intent stays callable by an agent (which can pass the string too) and your state stays typed:
intents: {
submit: intent({
description: 'Subscribe an email address',
input: schema({ email: str().min(3), age: str(), optIn: str().optional() }),
run: ({ state, input }) => {
state.age = Number(input.age); // "42" → 42
state.optIn = input.optIn === 'on'; // absent → false
},
}),
},If you'd rather keep a numeric contract for agents (age: int()), give the agent that intent and let the form call a separate string-taking one that converts and delegates. Two names, one behavior — and neither lies about what it accepts.
Validation is the schema, not the markup#
HTML attributes (required, type="email", min) give the browser its native UX. The contract is the intent's input schema — that's what runs on the server, what an agent sees as JSON Schema, and what rejects a hand-crafted request. Keep both: the attributes for humans, the schema for correctness.
A failed validation throws a JanuxIntentError before run executes, so run only ever sees well-typed input.
The agent gets the form for free#
Because the form is an intent, the copilot can subscribe someone without touching the DOM:
signup.submit { "email": "ada@example.com", "plan": "pro" }Same validation, same guard, same audit entry. If that shouldn't happen unattended, declare guard: 'confirm' and the agent's call returns a proposal a human approves (intents and guards).
Live feedback while typing#
Add reset to the <form> when the fields should clear on submit — a chat box, an "add another" form. The runtime calls the platform's form.reset() after capturing the values, so each control goes back to its rendered value; leave the field uncontrolled (no value prop) and that is empty. Doing it from state doesn't work: a controlled write is skipped while the control has focus, and Enter keeps it there.
intent={...} fires on submit. For per-keystroke behavior (availability checks, character counters) bind onInput to a separate intent — it's debounced by the delegated event surface and IME-safe:
<input name="handle" onInput={intents.checkHandle} value={state.handle} />See events and interactions for controlled inputs, and views and JSX for the full event table.
Uploads#
File inputs work the same way — the entry is a File. For drag-and-drop, paste and progress, use dropzone with an HTTP handler endpoint.
Related: Intents and guards · Views and JSX · Schema types