Ask forms
Overview
A suggested question is one line of text a user can send with one tap. An ask form is the same idea for the questions that need answers first: a chip with a form glyph, a short sheet above the composer, and a message rendered from what the user filled in. The message it sends is an ordinary user message. That is the design decision the whole feature rests on — it is why forms work with every CLI adapter without any of them learning a new shape, and why the transcript reads the same whether a line was typed or filled in. Everything below is about keeping that true without making it a place where things get lost. Forms are configured per agent and ride the ordinary agent PATCH; seecrewship agent for the command and the JSON.
A worked example ships with the demo data: crewship seed gives Casey, the
Test & Review Engineer, a Report a bug form — what went wrong, steps to
reproduce, severity, first seen, and a screenshot. Render it with crewship agent ask-preview casey bug-report, or read it at
cmd/crewship/seeddata/askforms/casey.json.
What a submission produces
Two things, not one:- The message. The template with
{{placeholders}}substituted, sent as the user. Plus the attachment block, if files were attached. - The submission envelope. Metadata carried with that message — never inside it:
submission_id is the part that matters most. Before it, the only handle a
submission had was the text it rendered to, and text is not an identity: two
identical submissions collided, and a reload lost the association entirely.
Files appear in field_attachment_ids and nowhere else, so one upload can
never look like the answer to two different questions.
The values under
field_attachment_ids are the agent-visible paths the upload
returns today. When canonical attachment identity lands they become opaque
att_… ids and the shape does not change — which is why the field is named
for identity rather than for paths.metadata field. There is no new
table, no migration, and a message written before any of this reads back
unchanged.
Field types
text · textarea · number · money · date · month · select ·
multiselect · checkbox · file · photo
The list is open. A type this console has never heard of renders as a text
input rather than as nothing, which is what lets a newer server offer a field
type without a coordinated frontend release.
With one exception, and it fails closed. A type whose name says the value
is a secret — password, api_key, client_secret, otp, private_key, and
anything else containing secret, password, token, credential, oauth,
cvv, or the whole words key, pin, otp, totp, ssn, pwd, auth —
is refused:
- when the form is saved.
crewship agent update --ask-formsand the console’s editor both reject it, naming the field. This is the actual guarantee: the server can never ship a type the console would mishandle. - when the form is opened, for a definition stored before this rule existed. The field renders no input at all, and the form cannot be submitted while it is there.
- and _, at most 32 characters.
Anything else is refused for the same reason: if the two renderers cannot agree
on what it is, neither should guess.
Constraints
A field may carrymin, max, pattern and multiple. What min/max mean
depends on the field:
A dash means the constraint is refused when the form is saved. That is
deliberate: a rule the submit path would not check must not be storable, or the
author is told a lie about what their form guarantees. So is
min above max,
and a pattern that does not compile.
pattern is anchored at both ends, exactly like an HTML pattern attribute —
CZ[0-9]{8} does not accept xxCZ25788001xx. It is compiled by Go’s RE2 when
the form is saved, which is what makes it safe to run in a browser: no
backreferences, no lookaround, nothing that can backtrack pathologically.
Every constraint is checked where the user answers, and every refusal names the
field by its label:
crewship agent ask-preview applies the same rules to --var answers and
prints the same sentences, because a preview that renders a message the chat
would refuse is not a preview.
An empty optional answer is never a violation — only required speaks to
emptiness — so min: 3 does not make a field mandatory.
Versioning a form
version is the author’s own revision number, optional and 1 when absent. It
is copied into every submission envelope, so a transcript written last month
can still be read against the questionnaire that produced it. Bump it when you
change what the form asks for; the definition moves, the answers do not.
Where each rule is enforced
There is no server-side submit endpoint to enforce answers at, because a
submit is an ordinary chat message and that is not being reopened for a
validator. What the server owns instead is the definition — which is why the
rows above that could produce an unsafe or unenforceable form are all refused
on the way in.
The rules themselves live in one place per language,
internal/askforms and
lib/ask-validate.ts, pinned to each other by testdata/ask-field-types.json.
A rule added on one side and not the other is a red test run in both suites.
Troubleshooting
“This agent’s stored ask forms are not valid.” A definition in the column breaks a rule the validator now enforces — most often a field type that names a secret. The whole column is refused rather than half of it; fix the definition and re-save withcrewship agent update --ask-forms @forms.json.
A field shows an explanation instead of an input. Its type failed closed.
The form cannot be submitted until whoever configured it removes the field.
No via <form> badge on a message. The badge comes from the submission
envelope; a message typed by hand has none, and neither does one sent from a
different device before the envelope was carried.