The fifth subsystem in glove-memory: structured collection over a conversation. You need eleven specific values from a user; a form gets them without turning the conversation into an interrogation, and without the agent ever reading the definition.
Definitions are code — Zod schemas, gate closures and executors colocated in one builder chain. The agent never sees them. It sees a projection of evaluated state: which step is open, what is still pending, and what is coming later.
Forms ship in glove-memory and can be used on their own — you do not need entity, episodic, resources or context to run one. If you do have them, ctx.memory inside an executor bridges straight through. See Memory for the other four.
A definition is a builder chain. Each .field() widens the accumulated values type, so every predicate and executor downstream is typed against the real shape — ctx.values.mode narrows to its enum union, ctx.values.mileage is number | undefined.
import { z } from "zod";
import { defineForm } from "glove-memory/forms";
export const travelClaim = defineForm({
id: "travel-claim",
version: 1,
name: "Travel reimbursement claim",
description: "Claimant, trip, travel and approval details.",
conduct:
"Conversational — one or two questions at a time. Don't read the field " +
"list aloud. If the user volunteers something out of order, capture it.",
})
.step("claimant", { title: "Claimant", preview: "name, staff id, email" }, (s) =>
s
.field("fullName", {
schema: z.string().min(2),
label: "Full name",
ask: "Get their full legal name as it would appear on a filing.",
})
.field("email", { schema: z.string().email(), label: "Work email" }),
)
.step(
"travel",
{
title: "Travel",
preview: "how they travelled, mileage or ticket",
when: (v, s) => s.stepComplete("claimant"),
},
(s) =>
s
.field("mode", { schema: z.enum(["car", "rail", "air"]), label: "Mode" })
.field("mileage", {
schema: z.number().int().min(1).optional(),
label: "Miles driven",
when: (v) => v.mode === "car", // only means anything for a car
}),
)
.checkpoint("policy-cap", {
when: (v) => typeof v.total === "number" && v.total > 750,
blocking: true,
waitMessage: "Checking this against policy — one moment.",
run: () => ({ fail: "Over the limit — needs Finance pre-approval." }),
})
.onComplete(async (ctx) => {
await ctx.memory.upsertNode("Person", { name: ctx.values.fullName });
})
.build();There is no required option. A field is optional iff its schema accepts undefined — the same predicate the inferred values type is built from, so the two can never disagree. The type string the agent reads is derived too, via z.toJSONSchema plus a small renderer: "email address", "one of: car | rail | air", "integer >= 1". Together those delete the field-type vocabulary entirely — no type union, no registry, nothing to extend.
There is no lock. Any value the agent can derive, at any point in the conversation, is accepted — the only thing that can reject a write is Zod. A user who answers question six while being asked question two has answered question six. glove_form_fill takes a patch of any field ids, validates each independently so one bad value does not reject the rest, and returns what landed.
Field ids are forgiving: full_name, Full name and fullName all resolve to the same field through an alias index built at compile time over normalised ids and labels. A definition whose fields would collide once case and punctuation are stripped is rejected at compile, so resolution is never a guess — and an id that still does not resolve comes back with did_you_mean rather than a bare rejection. Models guess ids confidently for fields they have not seen, and a bare miss costs a whole round trip.
Sequence is advisory, and splits into two unrelated things:
when — applicability. Whether a field means anything given current answers. mileage is meaningless on a rail trip. Inapplicable fields do not count toward completion and are not asked about — but a value supplied for one is kept.ask: true means “steer toward this now”; the agent stays free to follow the user elsewhere and come back.entries maps each field to an append-only log of revisions plus a cursor naming the one in force. Nothing is ever removed or rewritten — a correction appends, it does not overwrite — so any earlier answer stays readable and any change stays reversible. A retraction is a revision too, which is what makes retract, undo and redo pure cursor moves.
await runner.retract("ticketReference"); // withdraw, keeping the answer
await runner.undo(); // last answer anywhere on the form
await runner.undo("mileage"); // or on one field
await runner.redo("mileage");
await runner.history("mileage"); // every answer ever givenThe agent reaches all four through glove_form_revise's action parameter — set, retract, undo, redo — rather than four separate verbs. Tool schemas are re-sent on every model call, and an agentic evaluation measured them at roughly three quarters of this surface's whole context cost; an enum on a verb the model already has is far cheaper than three more definitions.
On top of that log, what changes is which entries are live:
| Term | Meaning |
|---|---|
| entry | An answer the user gave for a field |
| applicable | field.when(liveValues, state) === true |
| live entry | An entry whose field is applicable |
values | Derived: the live entries — what counts |
held | Derived: the non-live entries — kept, doesn't count |
Held means the user told us this, and it is not relevant right now. Either it was answered before it applied (“I drove 40 miles” landing before mode), or a revision orphaned it (car → rail). Change the answer back and the entry is live again, with the original value intact.
Repartitioning — the recomputation of the live set — runs on every commit and is not a data move: assume every entry is live, evaluate each when, drop the entries whose gate returned false, repeat until the set stops shrinking. Shrink-only, so it always terminates, and the common case is one pass.
Completion counts applicable required fields only. A claim with a held mileage on a rail trip is complete without it, and form.onComplete receives values, never held.
Four colocation points behind one signature:
| Hook | Fires when |
|---|---|
field.onFill | That field's entry crosses into the live set |
step.onComplete | Every applicable required field in the step is valid |
checkpoint.run | The checkpoint's when first holds |
form.onComplete | Every applicable required field is valid |
Dispatch is commit-then-run: values and the rising-edge log commit in one atomic write, then executors run. At-least-once with a per-occurrence idempotencyKey (${instanceId}:${hookId}:${occurrence}) — a retry reuses the key, a genuine second crossing gets a fresh one, and whether a repeat is real work is the executor's call.
An executor hands back { patch } (derived values, committed like any other write), { fail } (a blocking checkpoint rejecting), { jump }, { complete: true }, or { terminate: reason }. ctx.memory bridges to the other four subsystems — upsertNode, connect, recordEpisode, writeResource, setContext — with provenance supplied by the engine.
A checkpoint is a trigger: a condition over values, fired on its rising edge, running an executor. Returning { jump } moves the open step — forward to skip ahead, or back to a step that already finished.
.checkpoint("verify-identity", {
when: (v) => v.claimValue > 10_000,
run: () => [
{ patch: { verificationRequired: true } },
{ jump: "claimant" }, // go back and re-check who we're talking to
],
})An executor may return one effect or an array of them, so a router can stamp a derived value and move in the same firing.
A backwards jump is a revisit: the step's answers stay filled but come back with ask: true, because there is no point being sent somewhere every field reads as settled. Tier 0 says so too — [form: x] back at step 1/3 "Claimant" — go through it again — and it says it even when the form had already completed, since a silent jump is the same as no jump at all.
A router branches on both halves of the state. when and run each get a FormState — step completion, which checkpoints have fired, whether the form is done — alongside the typed values, so a trigger can route on where the conversation has been and not only on what it holds.
.checkpoint("route", {
when: (v, s) => Boolean(v.kind) && s.stepComplete("triage"),
run: (ctx) => ({
jump: ctx.state.stepComplete("triage") && ctx.values.kind === "complex"
? "complex-detail"
: "simple-detail",
}),
})checkpointFired reads the same counters the gate saw, so asking about a checkpoint inside its own run reports whether it fired before — not the firing in progress.
{ terminate: reason } stops the form outright, for the cases where carrying on would be wrong rather than merely unfinished — ineligible, duplicate, withdrawn.
.checkpoint("eligibility", {
when: (v) => typeof v.age === "number" && v.age < 18,
run: () => ({ terminate: "Under 18 — not eligible for this scheme." }),
})It is neither of the two effects that already existed: fail records a rejection and lets the conversation carry on, and complete claims the form succeeded. terminate closes the instance with the reason on closedReason, stops every field asking, refuses further writes, and takes the form out of tier 0. It beats a completion that would otherwise have landed on the same commit — an ineligible claim must not read as a finished one.
A jump is a nudge, not a pin. The override is released by the next write that lands in the step it sent you to, after which ordering goes back to being derived. A jump naming a step that does not exist is ignored.
Modelled on the inbox — a cheap standing notification, detail pulled on demand. Tier 0 is one line appended to the system prompt each turn, the way useContext injects:
[form: travel-claim] step 2/4 "Trip" · pending: Destination, Departure date
later: Travel (how they travelled, mileage or ticket) · Approval (cost centre, manager)Pending labels rather than a count, because “5 fields pending” would force a tool call every turn just to learn what to ask. A one-line preview per remaining step, because that is what makes opportunistic capture work without loading the whole form — an agent that hears “I drove, it was about 40 miles” during step 2 can see travel is coming. Asks, hints, enum options, validation rules and every field outside the open step stay out.
Tier 1 (glove_form_status) is the open step in full. Tier 2 (glove_form_inspect) is any named step, a single field, or the whole outline — with gated-off fields marked ask: false, so the agent can answer “what else will you need?” without promising something a branch may skip.
Registry-level laziness — form modules are not imported until started. glove_form_list renders name and description from the registration; compileForm runs on first start, then caches.
useFormRunner folds seven tools; glove_form_history comes from the reader registration, so an agent can read past fills without being able to write.
| Tool | What it does |
|---|---|
glove_form_list | Registered forms, name + description — no module load |
glove_form_start | Begin an instance, with optional seed values |
glove_form_status | The open step in full (tier 1) |
glove_form_inspect | Any step, field, or the whole outline (tier 2) |
glove_form_fill | A patch of many fields at once; returns re-evaluated state |
glove_form_revise | Amend an earlier answer — set / retract / undo / redo |
glove_form_abandon | Close out with a reason |
glove_form_history | Read past fills (reader registration) |
import { FormRegistry } from "glove-memory/forms";
import { InMemoryFormAdapter } from "glove-memory/in-memory";
import { useFormRunner, useFormReader } from "glove-memory";
const registry = new FormRegistry().register("travel-claim", {
name: "Travel reimbursement claim",
description: "Claimant, trip, travel and approval details.",
// Not imported until a form is actually started.
load: () => import("./forms/travel-claim").then((m) => m.travelClaim),
});
const { runner } = useFormRunner(glove, new InMemoryFormAdapter({ schema }), {
registry,
subject: conversationId,
memory: { entity, episodic, resources, context }, // optional bridge
});
// A second agent that can read past fills but never write:
useFormReader(auditor, adapter, { registry });useFormRunner folds the tools and wraps processRequest for tier-0 injection (injectStatus: false turns that off), then hands back the runner so a host can start instances and resolve checkpoints without going through the model — which is how a blocking checkpoint gets its answer from your backend rather than from the conversation.
Verified by probe, and worth knowing before you wire this to anything real:
field.onFill → step.onComplete → checkpoint.run → form.onComplete.onComplete therefore fires the moment the form starts.awaiting indefinitely. Writes are refused with form_blocked until resolveCheckpoint is called. There is no timeout; a host that can crash mid-checkpoint should recover them on startup.recordDispatch writes outside the CAS envelope. A concurrent commit can lose dispatch bookkeeping, which costs a duplicate executor run — the exact thing the idempotency key exists to absorb.revise / retract / undo still resolve against it; only abandon closes it. Tier 0 stays quiet once complete.FormAdapter is a storage-and-retrieval contract and nothing more. The engine holds every semantic — liveness, applicability, rising edges, completion — and recomputes them from whatever you hand back, so an adapter that persists FormInstance faithfully is a correct adapter whatever it is built on. Four invariants, and they are the whole of it:
entries appends, never replaces. commitInstance receives a FormEntryCommit per field ({ append?, cursor? }), not a FieldHistory. Append to the existing log, move the cursor, then clamp it. Overwriting a field's log destroys answers the design guarantees are kept — applyEntryCommit is exported from glove-memory/forms so you can reuse the exact semantics rather than re-derive them.version is compare-and-set. Reject a stale ifVersion with FormConflictError; bump version on every write that lands. The runner retries a conflict — it relies on losing, not on winning.Everything else is yours: storage engine and schema, indexing, retention, how you achieve atomicity, how much provenance you keep, multi-tenancy, encryption, soft deletes. The contract deliberately does not model any of it. Per-method detail lives in doc comments on the interface, and InMemoryFormAdapter is short enough to read end to end before writing your own.
Instances pin defVersion at start. When it stops matching the registered definition the runner does not guess: the default is status: "stale" with the reason surfaced, and a definition may supply migrate(old, fromVersion) to carry values forward. Bumping version is the developer's signal that a change is breaking — additive changes do not need it.
start and resolveCheckpoint; the engine only reacts.ctx.memory bridges to