What a form posts to
Most of a page tells a visitor something. Sooner or later one of them has to ask — a name, an email address, a sentence about what they want — and then that typing has to go somewhere.
Here is a form, built out of the same registered primitives as everything else on this site:
Contact
Tell us what you are building
A paragraph is plenty. One of us reads every one of these.
Every one of these goes through the same pipeline a model’s answer would: interpreted, analysed, weighed, judged, applied, appended. The log is real and it is in this tab — reload the page and the example is back as it was.
show the treehide the tree
{
"kind": "element",
"id": "n_contactform16",
"type": "loom.page",
"props": {
"fills": true,
"width": "readable",
"loom:theme": {
"palette": "minimal",
"fontPack": "minimal-sans",
"stylePreset": "precise"
}
},
"children": [
{
"kind": "element",
"id": "n_contactform15",
"type": "loom.section",
"props": {
"tone": "surface",
"width": "readable",
"eyebrow": "Contact"
},
"children": [
{
"kind": "slot",
"id": "n_contactform3",
"name": "heading",
"children": [
{
"kind": "element",
"id": "n_contactform2",
"type": "loom.heading",
"props": {
"level": 2
},
"children": [
{
"kind": "text",
"id": "n_contactform1",
"value": "Tell us what you are building"
}
]
}
]
},
{
"kind": "element",
"id": "n_contactform5",
"type": "loom.prose",
"props": {
"measured": true,
"tone": "muted"
},
"children": [
{
"kind": "text",
"id": "n_contactform4",
"value": "A paragraph is plenty. One of us reads every one of these."
}
]
},
{
"kind": "element",
"id": "n_contactform14",
"type": "loom.form",
"props": {
"layout": "stacked",
"width": "readable"
},
"children": [
{
"kind": "element",
"id": "n_contactform6",
"type": "loom.field",
"props": {
"name": "name",
"label": "Your name",
"required": true,
"autocomplete": "name"
},
"children": []
},
{
"kind": "element",
"id": "n_contactform7",
"type": "loom.field",
"props": {
"name": "email",
"label": "Email",
"type": "email",
"required": true,
"autocomplete": "email",
"hint": "We reply to this address and to nothing else."
},
"children": []
},
{
"kind": "element",
"id": "n_contactform8",
"type": "loom.field",
"props": {
"name": "message",
"label": "What are you building?",
"type": "textarea",
"required": true,
"span": "row"
},
"children": []
},
{
"kind": "slot",
"id": "n_contactform11",
"name": "submit",
"children": [
{
"kind": "element",
"id": "n_contactform10",
"type": "loom.button",
"props": {
"variant": "primary",
"scale": "large",
"width": "full"
},
"children": [
{
"kind": "text",
"id": "n_contactform9",
"value": "Send it"
}
]
}
]
},
{
"kind": "slot",
"id": "n_contactform13",
"name": "note",
"children": [
{
"kind": "element",
"id": "n_contactform12",
"type": "loom.perk",
"props": {
"label": "We reply within two working days",
"state": "included"
},
"children": []
}
]
}
]
}
]
}
]
}Look at what it says. The fields are greyed out, the button cannot be pressed, and there is a line above them explaining why.
That is not a bug and it is not a half-finished example. Nothing in that tree says where the form posts, so the form will not pretend it can send anything. A form that looked ready and quietly dropped what somebody typed would be the worst possible failure here, and it is the one this whole page is arranged to prevent.
The address is not in the tree
Everything else a page needs is in its tree. The heading is, the fields are, the label on the button is. The address a form posts to is not, and never can be.
The reason is the difference between reading and writing. If a node asks your app the wrong question, a visitor sees the wrong list of prices. If a form posts to the wrong address, a visitor's name, email and message are sent to whoever owns that address. Those are not the same kind of mistake, and they do not deserve the same kind of protection.
So a tree names a destination instead of carrying one:
"loom:submit": { "to": "contact.enquiry" },
One key, and deliberately nothing else. contact.enquiry is not a URL. It is a
name your deployment registered, the way a primitive type is a name your
registry registered, and it reaches exactly as far as whatever you put behind
it.
Your half: registering an endpoint
An endpoint is a name, a line about itself, and a function that says where the form should post.
import { createEndpointRegistry, defineEndpoint, describeEndpointRegistryError } from "@jam-overture/loom"
const enquiry = defineEndpoint({
id: "contact.enquiry",
description: "Sends an enquiry to the team's shared inbox.",
endpoint: {
/** Called once per page that has this form on it, before anything is drawn. */
target: async ({ context, signal }) => {
const token = await tokens.mint(context, { signal })
return {
ok: true,
value: {
action: "/contact",
method: "post",
fields: [{ name: "csrf", value: token }],
},
}
},
},
})
const built = createEndpointRegistry([enquiry])
if (!built.ok) throw new Error(describeEndpointRegistryError(built.error))
const endpoints = built.value
If that shape looks familiar it is meant to: it is defineSource from
where the content comes from
with the arrow pointing the other way. A deployment declares what exists, a
registry is the allowlist, and nothing in a tree reaches past it.
Three things come back, and the third is the one people are surprised by.
action is where to post. method is get or post. fields are hidden
inputs the form renders for you, in order — which is how a CSRF token gets onto
a form nobody typed it into.
That token is also the answer to why is any of this asynchronous. Minting one usually means a round trip to a session store, and a seam that could not wait for one would push every deployment into minting it somewhere else and threading it through by hand.
The same form, connected
Here is the identical tree with loom:submit on its form, resolved against the
endpoint above as this page was built:
The same tree, served by a deployment that registered {"to":"contact.enquiry"}
Contact
Tell us what you are building
A paragraph is plenty. One of us reads every one of these.
| What the tree says | What the deployment answered |
|---|---|
| "loom:submit": {"to":"contact.enquiry"} Sends an enquiry to the team's shared inbox. | post /contacthidden: csrf=a-token-minted-for-this-request |
Nothing about the form changed. The fields are the same nodes, the button is the same node, the layout prop is the same. What changed is that somebody answered, and the seam had a target to hand the primitive.
The action, the method and the hidden field under the frame were written by
the endpoint, not by this page. The token is a fixed string here because a
documentation build that minted a different one every time would serve HTML that
disagreed with itself — a real one comes out of your session store.
When it is resolved, and why not later
Rendering a tree is synchronous. renderLoomTree walks the nodes and
returns an element; it never waits for anything, because a render that could
wait is a render that can hang halfway down a page.
So the asking happens first, in its own step:
import { renderLoomTree } from "@jam-overture/loom/react"
import { resolveTreeSubmissions } from "@jam-overture/loom"
const submissions = await resolveTreeSubmissions(tree, { registry: endpoints })
const { element } = renderLoomTree(tree, { resolver, validator, themes, submissions })
resolveTreeSubmissions reads the tree once, collects every endpoint it names,
asks all of them at the same time, and hands back a lookup the walk can use
without waiting. Two forms naming one endpoint are one question and one
shared answer — which is not only cheaper but correct, since a nonce minted twice
would break whichever of the two a visitor did not use.
If you are serving a whole request, renderRequest does both halves for you:
give it sources and endpoints and it resolves the data seam and this one
together, rather than one after the other.
What a model may name
This is the whole of what a model is shown when it is asked to put a form on a page:
2 destinations, an id and a line each — and 1 of the library's 106 primitives can post at all
| Id | What it receives |
|---|---|
| contact.enquiry | Sends an enquiry to the team's shared inbox. |
| newsletter.subscribe | Adds an address to the monthly list. |
A page carrying 2 forms that both name newsletter.subscribe is 1 question, asked once, and one target shared between them.
An id and a line. No address, no method, no fields, nothing to compose. Whether the endpoint posts or gets, where it posts to and what it carries are resolved after the choice has been made, and none of them is the model's business.
Moving a form is never quiet
Picking the wrong destination out of your own list is still a real change, so the runtime treats it as one. Below are two asks against the same form, judged by the policy a deployment has before it has configured anything:
Two changes to the same form, under the policy a deployment has before it configures anything
| The ask | What it comes to | What the Gate said |
|---|---|---|
| “put the contact fields in two columns” | configure sets layout | accepted low · within-policy reversible, within the stakes ceiling, and confidently interpreted |
| “send the contact form to the newsletter list instead” | configure sets loom:submit.to | requires-confirmation high · redirected-submission redirects a submission: n_contactform14 from contact.enquiry to newsletter.subscribe |
Repointing a form is held for a person on every origin, with nothing registered and nothing switched on. That is a rule in the Gate rather than a stakes level, and the difference matters: a level alone cannot say never apply this without asking, because how much latitude an origin gets is a setting, and where a visitor's typing is sent should not depend on who asked for it to move.
Note what is not refused. Splitting one mailing list into two and repointing the forms is an ordinary thing to want, and a runtime that made it impossible would just mean nobody used the seam. The requirement is that a person sees it.
Writing an address into the tree does not work either
The obvious way around all of this is to stop naming a destination and start carrying one. It is worth seeing what happens, because the answer is not the Gate catches it:
A declaration with an address written beside the name, read by the planner
| What was written into the tree | What the planner did with it |
|---|---|
| {"to":"contact.enquiry","action":"https://forms.example.net/collect"} | loom:submit: Unrecognized key(s) in object: 'action' 0 endpoints planned, so the form has no target and renders disabled. |
The declaration is refused where it is read. loom:submit accepts one key, and
anything else makes the whole declaration unreadable — so a form carrying an
address has no target at all, rather than an address somebody sneaked past.
Most parsing of stored JSON is forgiving, and this deliberately is not. Dropping an unknown key quietly is right for a document written by an older schema; here the one thing being kept out of a tree is an address, so a declaration carrying one is refused out loud.
The named ways there is no target
Registering an endpoint is not a promise that it will answer. Every way that can go wrong has a name, and here is each one, reached by an endpoint that really behaves that way:
5 ways a form ends up with nowhere to post, and the sentence each one writes
| What happened | Reason | What the diagnostics say |
|---|---|---|
| The tree names an endpoint the deployment never registered trouble.never-registered | no-such-endpoint | no endpoint is registered for it — registered: trouble.refused, trouble.unavailable, trouble.threw, trouble.off-origin |
| The endpoint will not take this submission trouble.refused | refused | the endpoint refused — this form is closed to signed-out visitors |
| The endpoint could not answer just now trouble.unavailable | unavailable | the endpoint could not be reached — the token store did not answer |
| The endpoint threw instead of answering trouble.threw | endpoint-threw | the endpoint threw instead of answering — Cannot read properties of undefined (reading 'secret') |
| The endpoint answered with an address the seam refuses trouble.off-origin | invalid-target | the endpoint answered with a target the seam refuses — action: must be a same-origin path or an absolute http(s) URL |
Five reasons, and they matter to two different people.
To you, they are different problems. no-such-endpoint is a typo or a tree
that outlived a registration. endpoint-threw is a bug in your code.
invalid-target is your own composition mistake. unavailable is worth trying
again and refused is not — which is the one distinction the codes exist to
let you act on.
To a visitor, there is nothing to act on at all, so they are told something a person can use:
3 sentences loom.form declares, and none of them is a reason code
| When | What the page says |
|---|---|
| untargeted | This form is not connected yet, so it cannot be sent. |
| unavailable | This form cannot be sent just now. Please try again in a moment. |
| refused | This form is not accepting messages at the moment. |
Three sentences, and not one of them says "endpoint". They belong to
loom.form rather than to the seam — declared text, so a deployment serving
another language replaces them through a dictionary. A form that is refused
gets its own sentence because it is the one case where trying again in a minute
is bad advice.
What the seam will carry as an address
The last check happens after your endpoint has already said yes.
Your code wrote that string, so this is not the suspicion the runtime has of an AI-authored URL. It is the check that a base URL that was empty, or a path joined out of a config value, does not become a live form action:
7 strings a deployment might answer with — 2 of them reach the form
| The endpoint answered | Which is | What the seam did |
|---|---|---|
| /contact | A path on this site. | carried the form posts to /contact |
| https://forms.example.com/enquiry | Another origin, said out loud. | carried the form posts to https://forms.example.com/enquiry |
| //forms.example.net/collect | Another origin wearing a leading slash. | refused the endpoint answered with a target the seam refuses — action: must be a same-origin path or an absolute http(s) URL |
| /\forms.example.net/collect | The same thing again, with a backslash a browser straightens out. | refused the endpoint answered with a target the seam refuses — action: must be a same-origin path or an absolute http(s) URL |
| javascript:fetch('/drain') | Not an address at all. | refused the endpoint answered with a target the seam refuses — action: must be a same-origin path or an absolute http(s) URL |
| contact | Relative to whatever route the form is rendered on, which nobody chose. | refused the endpoint answered with a target the seam refuses — action: must be a same-origin path or an absolute http(s) URL |
| Nothing, meaning “post to this page”. | refused the endpoint answered with a target the seam refuses — action: must be a same-origin path or an absolute http(s) URL |
The two middle rows are the reason this is a schema and not an if. Both begin
with a slash. Both look like paths. Both reach a different origin —
//forms.example.net is scheme-relative, and /\forms.example.net is the same
thing with a backslash the browser straightens out on the way.
Leaving your origin is still allowed. It has to be said, as a full https://
URL, so that crossing an origin is a thing somebody wrote down rather than a
thing that happened.
What this page does not cover
What happens after the post. The form posts to your route, with your fields,
and everything from there — validating, storing, replying, rate limiting — is
your application's, exactly as it would be without Loom. The seam ends at the
action attribute.
An endpoint that never answers. Waiting is bounded, and it is bounded here the same way it is for a model and for a data source: when nothing comes back is the page about that, and a form's endpoint is the third of the three doors on it.