Forms

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.

Defining a form

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.

forms/travel-claim.tstypescript
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();

Optionality and type come from Zod

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.

Writes are never gated

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.
  • Steps — ask order. A conversational grouping and a checkpoint boundary. ask: true means “steer toward this now”; the agent stays free to follow the user elsewhere and come back.

Entries, liveness and held values

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.

host-sidetypescript
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 given

The 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:

TermMeaning
entryAn answer the user gave for a field
applicablefield.when(liveValues, state) === true
live entryAn entry whose field is applicable
valuesDerived: the live entries — what counts
heldDerived: 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 (carrail). 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.

Executors

Four colocation points behind one signature:

HookFires when
field.onFillThat field's entry crosses into the live set
step.onCompleteEvery applicable required field in the step is valid
checkpoint.runThe checkpoint's when first holds
form.onCompleteEvery 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.

Triggers that steer the conversation

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.

typescript
.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.

typescript
.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.

Terminating collection

{ terminate: reason } stops the form outright, for the cases where carrying on would be wrong rather than merely unfinished — ineligible, duplicate, withdrawn.

typescript
.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.

Lazy loading

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:

text
[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.

The tool surface

useFormRunner folds seven tools; glove_form_history comes from the reader registration, so an agent can read past fills without being able to write.

ToolWhat it does
glove_form_listRegistered forms, name + description — no module load
glove_form_startBegin an instance, with optional seed values
glove_form_statusThe open step in full (tier 1)
glove_form_inspectAny step, field, or the whole outline (tier 2)
glove_form_fillA patch of many fields at once; returns re-evaluated state
glove_form_reviseAmend an earlier answer — set / retract / undo / redo
glove_form_abandonClose out with a reason
glove_form_historyRead past fills (reader registration)

Wiring

agent.tstypescript
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.

Operational notes

Verified by probe, and worth knowing before you wire this to anything real:

  • Hook order within one commit is fixed: field.onFillstep.onComplete checkpoint.runform.onComplete.
  • Only rising edges fire. A step that becomes incomplete fires nothing; completing again is a fresh occurrence with a new idempotency key.
  • A step with no applicable required fields is complete — including an all-optional step, whose onComplete therefore fires the moment the form starts.
  • A throwing executor does not roll back the write. Dispatch is commit-then-run, so the answer is durable; the failure is recorded and surfaced to the agent.
  • A recorded failure is not retried. At-least-once covers a crash before the outcome was recorded — a hook that ran and failed stays failed until its field crosses into live again. If you need retries, do them inside the executor.
  • A blocking checkpoint whose executor never returns leaves the instance 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.
  • A complete instance stays reachable. Finishing a form does not end the conversation about it, so revise / retract / undo still resolve against it; only abandon closes it. Tier 0 stays quiet once complete.

Writing a form adapter

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:

  1. 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.
  2. 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.
  3. A commit is all-or-nothing. Entries, occurrence counters, dispatch log and status land together or not at all. That is what makes commit-then-run dispatch safe.
  4. Reads hand back snapshots. Clone if your store could return a live reference.

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.

Def drift

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.

What forms don't own

  • Runtime-authored forms. Definitions are code — there is no JSON compile target, no authoring UI, and no second front end.
  • Compensating a re-fired executor. Hooks fire on every rising edge with a per-occurrence idempotency key; whether a repeat is real work is the executor's decision.
  • Scheduling and orchestration. A host drives start and resolveCheckpoint; the engine only reacts.
  • Memory — the other four subsystems, and the adapters ctx.memory bridges to
  • The Inbox — the standing-notification pattern tier 0 is modelled on
  • The Display Stack — when a step is better collected as a rendered form than as questions