Forms is a collection primitive for conversations — the fifth memory subsystem in glove-memory. This is the record of what we decided and why, and of the five defects a fifty-cent eval found that reading the code had not.
Every agent that collects something structured — an intake, an onboarding, a claim, a booking — ends up rebuilding the same four things badly. It keeps partial answers in the transcript, where they are one summarisation away from gone. It asks in a fixed order, so a user who volunteers question six while being asked question two gets asked question six again later. It cannot tell “not answered” from “does not apply”. And when the user corrects themselves, the old answer is overwritten and the correction is unappealable.
Forms exists to make those four failures structurally impossible rather than discouraged by a prompt. What follows is the honest version of how it got there — including the parts we got wrong on the first pass.
Almost every question about this subsystem answers itself once you know which representation you are holding. A FormDef is code in your repo — zod schemas, predicates, executors — and it is never serialised. compileForm turns it into a cached index. A FormInstance is the only thing storage ever sees. A FormView is rebuilt on every tool call and is the only thing the model reads.
The model never sees a definition. That single boundary is what lets the definition carry arbitrary TypeScript while the agent-facing surface stays a flat list of rows with a status each.
The tempting design is a JSON schema for forms, stored in a table, editable at runtime. We did not do that, and the reason is that the interesting parts of a form are not data. An applicability rule is a predicate. A validation is a zod schema. A side effect is an async function with access to the rest of memory. Encoding those as data means inventing a language for them, and that language will be worse than the one you already have.
Colocating them also buys type threading. Every .field() widens the accumulated values type, so a predicate written on the third field of the second step already knows about everything declared before it.
.field("incidentType", {
schema: z.enum(["vehicle", "premises", "medical"]),
label: "Type of incident",
})
.field("vehicleCount", {
schema: z.number().int().min(1).optional(),
label: "Vehicles involved",
// `v.incidentType` narrows to the enum union — not `unknown`, not `any`
when: (v) => v.incidentType === "vehicle",
})Step ids are threaded the same way, so state.stepComplete("identiy") is a compile error rather than a predicate that quietly returns false forever.
There is no required: true flag and no field-type vocabulary. A field is optional exactly when its schema accepts undefined — schema.safeParse(undefined).success — and the type string the agent reads comes out of z.toJSONSchema. Two sources of truth about the same fact is a bug waiting for a deadline, and this one had an obvious single source.
This is the load-bearing one. glove_form_fill takes a patch of any field ids, not just the step the conversation is on. Each value is validated independently, so one bad answer never throws away the good ones sent alongside it. And an answer that does not currently apply is not dropped — it is held.
Liveness is recomputed from scratch on every commit rather than decided at write time and remembered. That makes it a partition, not a mutation: an answer orphaned by a correction comes back the moment the correction is corrected, because nothing was ever removed.
when| Question it answers | If false | |
|---|---|---|
field.when | Does this question make sense at all? | Answer is held, not counted, still writable |
step.when | Should we be asking about this yet? | Step is not opened, fields do not ask, still writable |
Applicability is about meaning; ask-order is about conversation. Keeping them separate is what lets a form with fifty conditional fields finish in six questions without any of the fields becoming unwritable.
Executors hang at four points — field.onFill, step.onComplete, checkpoint.run, form.onComplete — and all four fire on a rising edge: the first commit where the condition holds having not held before. The answer is durable before any executor sees it, which is what makes at-least-once dispatch survivable. A crash mid-executor replays the hook; it never loses the answer.
Every edge bumps a per-hook counter whether or not an executor is attached, and that counter is the third segment of the idempotency key — ${instanceId}:${hookId}:${occurrence}. A retry reuses the key; a genuine second crossing gets a fresh one.
A form is only useful if the agent knows it exists, and only affordable if knowing that costs almost nothing. One line goes into the system prompt each turn; everything else is pulled on demand.
[form: pi-intake] step 2/4 "Incident" · pending: Type of incident, Date of incident
later: Injuries (what hurts, treatment) · Review (confirm and sign off)Two inclusions in there were argued for on cost and both survived. Pending labels rather than a count, because “5 fields pending” forces a tool call every turn just to learn what to ask — more expensive than the tokens it saves. And a one-line preview per remaining step, which is what makes opportunistic capture work: an agent who hears “I already have a lawyer” during step 2 can see representation is coming and grab it now.
Everything above reads as correct. The surface compiled, the tests passed, the types were tight. So we built examples/forms-bench — seven scenarios, two repetitions, four cheap tool-capable models across four vendors, with real token attribution and real spend reported by the provider.
The scenarios were chosen to be uncomfortable rather than representative: a user who front-loads every answer in one message, a user whose answer is orphaned by a later correction, a malformed id mixed into an otherwise-good patch, a user who takes something back, a value over a checkpoint's cap, and a “what else do you need?” turn that tests whether the agent can see past the open step.
Total spend for the whole programme, across every round of fixes: roughly fifty cents. It found five defects that reading the code had not.
| Symptom | Cause | Collection rate |
|---|---|---|
| 279 unknown field ids | 62% differed from a real id only in case or punctuation; 17% of write calls had every field rejected | 69% → 85% |
Half of all boolean writes sent "yes" | The type string read yes / no while the error said expected boolean | 85% → 90% |
| Good answers destroyed | Models wrote "" to retract, overwriting the single stored entry | 90% → 94% |
| Completed forms unreachable | Instance resolution excluded complete, so no post-completion correction was possible | one model: 9/14 → 12/14 |
| Routing silently dropped | A jump back to a finished step was ignored — the one thing a routing trigger could ask for and not get | revisit shipped |
Models do not reliably reproduce your field ids. They send Full name for fullName, staff_id for staffId. The engine rejected all of it, correctly and uselessly.
The fix is a compile-time alias index over normalised ids and labels, so all the spellings land on the same field. Any definition whose fields would collide once case and punctuation are stripped is now rejected at compile time, so resolution is never a guess. Ids that still do not resolve come back with did_you_mean suggestions rather than a bare rejection — without them the model has nothing to go on but another guess, and a wasted round trip was the most common friction on the surface.
describeType rendered z.boolean() as yes / no. It reads well. It is also a lie about what the schema accepts, and models believed it: they sent the literal string "yes" for half of all writes to a boolean field, got back expected boolean, received string, and looped. Twenty one of forty eight runs had a retry loop on this single string.
It now renders true or false, and a type mismatch on a quoted number or boolean gets a hint naming the JSON shape to send. Type strings name what the schema accepts, not a friendly paraphrase of it.
The third one was not a string. With one entry per field, a model writing "" to clear an answer overwrote the real one. The design said nothing is ever deleted; with a single entry per field, that was only true of applicability changes.
So storage became a per-field append-only revision log plus a cursor saying which revision is in force.
interface FieldHistory {
/** Oldest first. Append-only — nothing is ever removed or rewritten. */
revisions: FormEntry[];
/** Index of the revision in force. -1 means none. */
cursor: number;
}A retraction is itself a revision — retracted: true with no value — so “the user took that back” and “the user changed their mind” are the same mechanism. That collapses set, retract, undo and redo into cursor arithmetic over a log that cannot lose anything, and makes every one of them reversible. Blank writes went to zero.
All four ride on glove_form_revise behind an action parameter rather than shipping three new tools. Tool schemas are re-sent on every completion call, and the eval measured them at roughly three quarters of this surface's entire context cost. One enum on a verb the model already has is far cheaper than three more definitions — and “revise” is the honest word for all four moves anyway.
The same logic shaped the view. What undo and redo would do is surfaced as one line each at the view level, not as a flag on every field row: the agent needs to know the move exists, not to audit each field's depth on every call.
The adapter was heading toward a conformance test suite — a package that would tell an implementer whether their backend was correct. We dropped it. A conformance suite is a way of specifying behaviour by making the specification executable, and every assertion it makes is a decision taken away from the person writing the adapter.
What shipped instead is documentation with a sharp edge. Four invariants the engine actually relies on:
entries appends, never replaces. A commit carries a per-field { append?, cursor? }, not a whole FieldHistory. applyEntryCommit is exported so nobody has to reimplement it.version is compare-and-set. The runner retries a conflict a few times — it relies on losing, not on winning.And an explicit list of what is not specified: storage engine, schema, indexing, retention, how atomicity is achieved, provenance depth, multi-tenancy, encryption. An adapter's job is storage and retrieval. The engine holds every semantic — liveness, applicability, rising edges, completion — and recomputes them from whatever it is handed back.
A checkpoint is a trigger: a condition over values and prior state, fired on its rising edge. It could already jump the conversation forward. Jumping back to a step that had already completed was silently dropped — which is the one thing a routing trigger could ask for and not get.
A backwards jump is now a revisit. The step reopens, its answers stay filled but come back with ask: true, and tier 0 says back at step N/M "Title" — go through it again even on an otherwise-complete form. The override is released by the next write into that step, so a jump nudges rather than pins.
Two smaller additions came with it. Executors now receive the same FormState their gates do, so a router can branch on where the conversation has been and not only on the values it holds. And an executor may return an array of effects, so one firing can stamp a derived value and move in the same breath.
.checkpoint("triage", {
when: (v, s) => s.stepComplete("incident") && v.severity !== undefined,
run: ({ values, state }) => {
if (values.severity === "minor" && !state.stepComplete("injuries")) {
return [
{ patch: { track: "fast" } }, // stamp a derived value…
{ jump: "review" }, // …and route, in the same firing
];
}
if (values.priorClaimId) return { jump: "identity" }; // back — a revisit
},
})The other gap was ending a form for the right reason. { fail } records a rejection and lets the conversation carry on; { complete } claims the form succeeded. Neither fits ineligible, duplicate, or withdrawn. { terminate: reason } now stops collection outright — closes the instance with a closedReason, stops every field asking, refuses further writes, and beats a completion that would otherwise have landed on the same commit.
The friction is almost never in the engine. Four of the five defects were in strings the model reads — an id it could not echo, a type it could not parse, a verb whose description did not say what not to do. The state machine was fine the whole time. If you are optimising an agent-facing API, the descriptions are the API.
A surface that inspects as correct can be hostile in use. Nothing on that table was found by reading the code, and several of them are obvious in hindsight. The only way to find them is to run the thing against models that do not know what you meant.
Tool schemas are the dominant context cost. Not descriptions, not results — schemas, re-sent on every completion call. That measurement is why four verbs became one enum, and it should probably change how you count the cost of “just one more tool”.
Fix the harness before you trust the number. A max_tokens of 1024 starved the reasoning models entirely — they returned finish_reason: "length" with no content and no tool calls, which is indistinguishable from a model ignoring your tools. Two graders were measuring the wrong thing: one scored retraction, the better behaviour, as failure; another asked a genuinely ambiguous question and then marked the model down for answering it a defensible way. Both are recorded in the bench README rather than quietly corrected, because a benchmark whose fixes are invisible is not evidence.
Not everything belongs to you. The adapter conformance suite would have been good engineering aimed at the wrong target. The contract needed to be small enough that somebody could implement it over a store we have never heard of, and documentation does that where a test suite does not.
Switching to a different form definition mid-conversation has no effect type. A trigger can terminate the form it is in and an agent can start another, but there is no single move that hands the collected values across — and the instance and value semantics of that move are a product decision rather than an engineering one. It is deliberately unbuilt.
Forms ships as glove-memory/forms with an in-process reference adapter, seven agent tools plus a read-only eighth, and the eval harness under examples/forms-bench — which is worth running against your own forms, not just ours. The memory docs carry the full API.