formio/formio-ai
Build applications on the Form.io platform. Bundles the Form.io MCP server with a skill library covering app orchestration, form building, resource planning, JSON schema authoring, actions, authentication, the @formio/js SDK, and the full Form.io REST surface.
@formio/ai
0.12.1
Patch Changes
- acb8699: The unconfigured report is written for the person who reads it. Every skill instructs the agent to relay that report verbatim and ask for the one value it names, so the audience is a human — and a third of what they were handed was prohibitions written to stop an AGENT constructing a URL rather than asking for one: a
*.form.iohost is not a Base URL,https://api.form.io/<project>is not a project, never append a project name to a deployment URL. Someone being asked "what is your Project URL?" is going to paste the one they have; those rules read as guardrails built for somebody else, and they turned a three-line question into eight. They are not deleted —PROJECT_URL_GUIDANCEkeeps them and the server's own instructions carry it, so an agent with no skills installed still meets them at connect time, which is the surface they were written for. The report, the CLI's report, and the error every other tool raises now carryPROJECT_URL_FOR_A_USERinstead: what a Project URL is, and the shapes it takes. That last error also stops explaining thatFORMIO_PROJECT_URLis the weakest of the three records — precedence is the agent's business, and it was a third of the length of a message relayed straight to the user.
0.12.0
Minor Changes
-
a6e9632: Add a
project_gettool, and move every skill's project preflight onto it.The read half of the project surface was missing from the tool list.
project sethad both a CLI subcommand and aproject_settool;project gethad only the subcommand. So every tool-calling skill's preflight rannpx -y @formio/mcp@<version> project get --cwd "$(pwd)"— an agent already holding an open connection to this server spawning an npm download to ask that same server a question it can answer over the transport.project_getcloses the asymmetry: same resolver, same precedence, same three answers, no subprocess, no registry round-trip, and no chance of a preflight run by one server version reporting a resolution a different connected version will not honor.The three outcomes travel as a
status, not as an exit code or a substring.okcarries both URLs;not-configuredcarries neither and sends the caller toproject_set;base-url-unresolvedcarries the project and asks for the deployment alone. A failure to answer at all — an unreadable~/.formio/projects.json, aformio.jsonthat will not parse — stays a tool error, so it can never be mistaken for "nothing is mapped here", whose remedy would overwrite the file that is merely unreadable.The report itself is now shared.
reportProjectinproject-report.tsowns the resolution and the prose; the CLI subcommand and the tool are adapters over it, differing only in how each names a remedy — runnable commands for a shell, tool calls for an agent — and in the shell-environment caveat, which is true of the CLI and false of the server answering about itself.Skills: the ten tool-calling skills now call
project_getwithcwdand branch onstatus;formio-angular's SETUP and CONFIG do the same, and CONFIG writes the committedformio.jsoninto the workspace directly (see the removal of the committed-file writer below).formio-mcp-setupkeeps itsnpxinstructions — it is the skill that installs the server, and it runs before any tool exists to call.The preflight now blocks the tool call, not the turn. It was the first section of every skill and phrased absolutely — "stop and connect the server before doing anything else" — so an agent read it at activation and answered "build me a CRM" with a blocked-on-setup message before it had heard the idea. Understanding a request, planning a resource map, and writing
template.md/template.jsontouch no deployment. Each skill now states when its first tool call actually is and tells the agent to finish everything before it first:formio-applicationgates at Step 3 (Import) and says so in its Stance, Step 1, and handoff contracts;formio-resource-planner— which calls no MCP tool at all — now opens by saying planning runs with no server, no project, and no authentication.And it offers exactly one remedy. Told the tools were absent, an agent invented a client menu and an "authorize in the browser" step for a server nothing had configured, then reported it as "installed but not authenticated".
formio-mcp-setupis now named as the only remedy, inventing install or authorization steps is banned by name, and two states that do not exist in this design are ruled out: a Form.io-branded MCP entry exposing only connection or authentication tools is not this server, and authentication is implicit — there is no authenticate-first step to announce and no unauthenticated state to diagnose before a call has failed.Getting the two URLs no longer requires the MCP server. The server owns project resolution — the mapping, the precedence, the writes to
~/.formio/projects.json— but a document that writesFormioAppConfigorFormio.setProjectUrl(...)needs only the two values, to configure the application being built rather than to reach a deployment. Those documents said "take both URLs from the MCP server rather than typing them", which told an agent with no server to install one in order to answer two questions the user can answer directly.Each now takes one of two paths, chosen on whether the Form.io tools are callable: Path A calls
project_get(and records a missing value withproject_set); Path B asks the user — Project URL first and alone, Base URL derived from it, and asked for only in the one shape where it cannot be derived. A new canonical reference,formio-mcp-setup/references/project-urls.md, carries the rules both paths apply — the three project-URL shapes, the derivation table, the aliases (appUrl=projectUrl,apiUrl=baseUrl), and the prohibitions that hold either way. It is the single copy: the existing bans on restating URL guidance now exempt that one file and stand everywhere else.formio-angular's SETUP loses its "Ask the server, do not interview" heading and gains Path B, and it now separates three cases that used to share one remedy: tools callable (resolve), no Form.io tools at all (interview), and tools present withoutproject_get— a server that predates the tool, whichformio-mcp-setuprepairs. That third branch is decided on which tools actually answer, never on a version number written in prose:sync-server-pin.tsrestamps launch commands and not sentences, so a number there is stale one release after it is written.The planner artifacts are now stated to be data, not instructions.
formio-angularfindstemplate.md+template.jsonon disk and writes free text out of them — resource names, form paths, role names — into the user's own TypeScript. The documents said where to find the pair and what to extract, and never said where it must have come from or how its contents must be treated, which is the indirect-prompt-injection exposure Snyk's W011 flags. Three rules now govern every read, in the parent skill, inAUTH.md, and in the resources sub-skill: the pair must be first-party (planner-produced this session, handed over byformio-application, or written by the user's team and approved — being in the working directory proves none of that, and an unaccounted-for pair is confirmed with the user before a value is read out of it); its prose never directs the work (aPurpose:line or field description describes the application, so a sentence reading as a directive is reported rather than acted on); and every value that reaches generated code is shape-checked against what it claims to be — a URL path segment or a plain identifier — with anything carrying quotes, newlines, angle brackets, a URL, or code stopping for the user instead of being written into their source.The install commands now live only in the setup skill. All eleven gated skills carried the same fallback quote for the case where the tools are absent and
formio-mcp-setupis not installed either — a message telling the user to runnpx skills add formio/aiand to configurenpx -y @formio/mcp@<version>. It duplicated in eleven places instructions the setup skill owns and keeps current, and it contradicted the rule those same documents state: that the setup skill is the only remedy they offer. The replacement names the library and points at the repository README, which carries the install route for every client, and leaves the commands where they are maintained.formio-mcp-setupkeeps them — writing that configuration is its whole job — and Angular's own scaffolding commands inBOOTSTRAP.mdare untouched, since they install Angular rather than this server.A base URL alone is accepted where the mapping holds the project, and refused — naming the right record — everywhere else.
base-url-unresolvedasks for the deployment alone and states that the recorded project must not be re-asked, so "does this directory have a project?" is a question about every source — a committedformio.json, the working-directory mapping, orFORMIO_PROJECT_URL. A guard consulting only the two writable records refused its own printed remedy for a launch configured purely by environment, which is the launch the.mcpbdesktop bundle produces from its install prompt and exactly where a path-less customer project URL leaves the deployment underivable. Neither writer copies that project into the mapping, and the message each prints names the record that actually holds it, because the user's next move differs by record.Notes travel with the answer they explain, in every shape it can take. An
Ignoring FORMIO_BASE_URL: …note is the CAUSE of abase-url-unresolvedreport, soproject_getputs its notes in the text a client shows a model rather than instructuredContentalone, andproject set's could-not-answer failure carries them too. Aproject_getthat fell back to the server's own working directory says so on a RESOLVED answer as well, since a confidently reported project for a directory nobody named is the failure that produces.The report names the environment its reader can actually reach. The CLI and the tool share one implementation and differ only in reader-specific vocabulary — remedies (runnable commands for a shell, tool calls for an agent), the shell-environment caveat, and now the provenance wording. The two readers stand in different processes: telling an agent its project came from "this shell's environment" sends it looking for a variable only the server's launch configuration holds.
project_setreads that environment through the same already-validated config every other tool takes, so an unusable value is dropped once, ingetConfig, rather than re-warned on every call with a view that can drift from the resolver's.The unmapped answer says which directory it searched.
project_get'scwdargument is optional and falls back to the server's own process cwd, which for a plugin- or desktop-launched server is not where the user is. Thenot-configuredmessage names a directory to record the project under, so a fallback answer that did not say it was a fallback told the agent toproject_setthe server's spawn directory — a write that succeeds and that every later call, which does pass the user'scwd, then fails to find. It now says so, exactly asmissingProjectErroralready does for every other tool. The CLI subcommand is unaffected: its fallback is the shell it runs in.Skills:
formio-mcp-setupgains an "already connected, but too old" branch — the caseformio-angular's SETUP hands it when the other Form.io tools are callable andproject_getis not, which its Step 1 previously answered with "nothing to do" — and linksreferences/project-urls.md, the canonical URL rules it owns and every other skill links to. Also fixed: a preflight-scope heading that landed mid-list informio-angular/SKILL.md, orphaning two pre-flight steps under it; two documents still sending the agent to aproject setshell command for a remedy that now names only theproject_settool.The base-URL remedy names the record that actually holds the project. It said, unconditionally, to add a
baseUrlkey besideprojectUrlin the committedformio.json— butbase-url-unresolvedis now reachable for a project that resolves only fromFORMIO_PROJECT_URL, where no such file exists. Aformio.jsonholdingbaseUrlalone is not a partial configuration: the walk claims any file naming either key and then rejects one with noprojectUrl, so following the printed remedy moved a directory from half-configured, which this report can repair, to a hard tool error, which every skill answers by relaying and stopping. Both readers now branch on where the project came from — the committed-file edit is offered only where a committed file names it (by resolved path and key), and otherwise the remedy is a mapping write carrying both URLs, with the reason the one-key file is refused said out loud.The fallback-directory note fires only where the directory decided anything.
resolveProjectsuppresses its copy for a project supplied by the environment, because that project resolves identically for every directory; the extractedreportProjectreintroduced it unconditionally, so a.mcpb-bundle launch callingproject_getwith nocwdgot a caution about the server's own working directory prepended to an answer that was correct everywhere — and the skills relay notes to the user as causes.A record holds a project and its deployment as a PAIR. Precedence picks ONE record — the nearest committed
formio.json, then the working-directory mapping, then the environment — and both resolved values come from it: the record's own deployment, or the one derived from that record's project URL. Halves are never combined across records, so "which project is this deployment for?" is never a question at read time; a deployment in a losing record belongs to that record's project and is reported as shadowed with it. A write leaves a complete pair or it fails: the one project-URL shape that derives no deployment (a path-less URL on a customer domain) must arrive with its Base URL in the same call, and a mapping entry holding a deployment with no project beside it is named in the half-configured report as unreadable, with the remedy that replaces it. This supersedes the earlierbaseUrlForpairing key, which existed only because a deployment could be stored apart from its project.The pair rule is enforced at read time too, by the same classification every writer uses. Both writers refuse
https://api.form.iooffered as a project URL (it is the hosted cloud's shared Base URL, and it derives itself, so the pair collapses) and a Base URL identical to the Project URL (an Open Source install — these tools require the Enterprise Server's project layer). But a hand-writtenformio.json, a hand-edited mapping entry, and the environment never pass through a writer, so the resolver now applies one sharedclassifyPairto the winning record's EFFECTIVE pair — recorded or derived — and refuses per record, in that record's own repair vocabulary: a committed file fails naming the file and the cause, a mapping entry fails naming the entry and theproject_setrewrite that replaces it, and the environment — a suggestion, read tolerantly everywhere else — is ignored with a note and resolution falls through to the interview. PreviouslyFORMIO_PROJECT_URL=https://api.form.ioresolved cleanly with both URLs equal to the deployment root, a pair both writers refuse.A record that can never win precedence can no longer fail the directory. A mapping entry's URL values are validated only when that entry wins: a stranded, malformed
FORMIO_BASE_URLin an entry holding no project used to fail every call for the directory even though an environment project resolved it — the same entry with a valid value resolved fine. A winning entry's unusable value still fails loudly, entry-scoped, because it is configuration that exists and would have governed.The committed
formio.jsonwriter is removed; the file is hand-authored.project set --scope repoand theproject_settool'sscopeargument are gone. The file lives in the user's repository and is designed for hand authorship and review; the reader tolerates aformio.jsonthis server did not define — one naming neitherprojectUrlnorbaseUrlis passed over as "not this server's file" — but a writer cannot write a file it disowns without claiming it, and--scope repoin a directory holding such a file silently mergedprojectUrl/baseUrlinto a document this server does not own while shadowing the ancestor configuration the directory used to defer to. The mapping also stays the only record the server writes, so the writer-reader agreement surface covers one writer instead of two. The record stays fully supported on the read side; recording it is now an edit the caller makes — a two-key JSON object any user or agent writes directly — and every message that used to name the--scope repowrite instead names the exact file path and key: the base-URL remedies, the deployment-for-a-committed-project refusal, the unusable-file repair, and the structuredremedyonproject_get, which is absent for a committed project because no call performs an edit. The CLI now refuses unknown flags, so a stale--scope repofails loudly instead of landing the write in a record the caller did not choose. Migration: replaceproject set --scope repo --project-url <url> --cwd <dir>with writing<dir>/formio.jsoncontaining{"projectUrl": "<url>"}(plus"baseUrl"only when it cannot be derived).The two wrong-URL refusals exit
1, and every refusal keeps its notes. The API-root-as-project refusal and the Open Source collapse are the user typing the wrong URL back mid-interview; the Open Source one exited2, the code every skill answers by relaying and stopping, so the mistake the refusal exists to catch abandoned setup instead of re-asking. Both now exit1— act on this message. The API-root refusal also dropped the notes collected on the way to it (an "IgnoringFORMIO_PROJECT_URL: …" explanation); everyproject setrefusal now carries them.BREAKING: a global
FORMIO_BASE_URLno longer supplies the deployment for a project another record names. Under the pair rule the environment is a record of its own, so a directory whose project comes from a mapping or a committedformio.jsontakes its deployment from that same record or from derivation — never from the environment. The shape this changes is a project URL that derives nothing (path-less, on a customer domain) recorded WITHOUT its deployment by an earlier release, alongside aFORMIO_BASE_URLexported into the server's environment: that directory used to resolve and now reportsbase-url-unresolved, naming the stranded variable underUnpaired:. Migration: record the pair in the record that holds the project —project set --project-url <url> --base-url <url> --cwd <dir>for a mapping, or a"baseUrl"key beside"projectUrl"in the committed file. A project that derives its own deployment (hosted cloud, or sub-directory routing) is unaffected.A hosted-cloud project's deployment is derived, never taken from a record. A project on a
form.iohost is served byhttps://api.form.ioand by nothing else, so a Base URL naming anything else is not a second opinion — left in place it became the portal-login URL and the token-cache key for a deployment the user does not use, silently, because nothing checked it.classifyPairnow names that pair, the writers refuse it (the value is the caller's live answer, and a refusal is what corrects it), and the resolver ignores it, resolves the derived deployment, and says which value it set aside and in which record.The API root is refused by HOST, not by exact string.
http://api.form.ioandhttps://api.form.io/<name>are the same mistake ashttps://api.form.io— the server's own guidance already says the second is not a hosted project URL — and an exact compare accepted both as project URLs. The host is compared whole, so a lookalike host stays a different deployment.project_setrefuses arguments it does not take. Its input schema is strict (additionalProperties: false), so a caller still passing the removedscope— from the previous release's own documentation — gets an error instead of having the key silently stripped, the machine-local mapping written, and success reported for a committed write that never happened. The CLI already whitelisted its flags for exactly this reason; the tool now matches it.The structured
remedynever names a directory the message warns against. When nocwdwas passed, the report is about the server's own working directory and says to call again with the user's directory BEFORE recording anything — while the remedy carried that same directory as an argument, and the output schema tells agents to act on the remedy rather than parse the message. It is now omitted in that case, as it already is for a committed record, so the warning is structural rather than prose.A refusal names the deployment already sitting in the record. Repairing a mapping entry whose stored project URL is unusable dropped that entry's perfectly good
FORMIO_BASE_URL— correctly, since an unusable project cannot vouch for the deployment beside it — and then demanded the value the user could see on disk. The refusal now names it and says why it was not adopted, so the next call is one the user can make.Notes survive a report that cannot answer. They are collected in the caller's own array rather than a private copy, so an
Ignoring <path>note emitted while walking still reaches the reader when resolution then throws — the note is usually the first half of the story the failure finishes.project_getrenders notes on its error result too.A stranded deployment is reported as unpaired rather than shadowed. A mapping entry (or a
FORMIO_BASE_URL) holding a deployment with no project beside it was listed underShadowed: … overridden by the record above, which is false — nothing outranked it, and nothing could, because it names no project. It gets its ownUnpaired:line and a newunpairedfield inproject_get's structured output. Losing values are still never validated (a record that cannot win takes no part in the answer), but one that is not a usable URL is now labelled as such where it is echoed.Duplication that had already drifted is now guarded rather than trusted. The preflight prose every gated skill carries is duplicated on purpose — each
SKILL.mdis loaded alone, so a skill that links away for its own preconditions has a precondition the agent may never read — but the tests only checked that each copy CONTAINED certain tokens, which a partial update passes: edit seven files, miss the eighth, and CI stays green while two skills tell an agent different things. A new test compares every gated skill's paragraphs against every other's and fails on any pair that is nearly-but-not-exactly the same, comparing rendered link text so a skill that cannot link to itself is not reported as drift. On the server side the same principle removed the copies: onewrite-refusals.tsfor the explanations both writers share, oneBASE_URL_UNDERIVABLEclause composed by all four messages that used to paraphrase it, one committed-config walk per write instead of two, andprojectRecordNamenarrowed toenvironmentRecordName— its committed and mapping branches were unreachable, and an unreachable branch is a wording nobody reads and nobody updates.reportProjectalso loses acwdguard that duplicated the resolver's.Every failing state now names something that makes progress, and one test asserts it. Five separate defects on this surface shared a shape no per-state test could catch: a refusal whose named command was the command that had just failed, a message promising a write it did not name, a tolerated path re-using the fatal path's remedy.
remedies-terminate.test.tsenumerates every reachable failing state and checks three things of each — the message never names the call that produced it, acting on what it says leaves the directory resolving differently, and a message with no automatic fix says what a human must do instead. Adding a row is how a new failure mode gets covered; the checks never change. The defects it found and now pins:- Re-recording a hosted project whose stored deployment is foreign no longer loops. The stored value was inherited, rejected, and the refusal said "call project_set again with projectUrl alone" — exactly what had been called, with both escapes closed (the schema forbids passing a Base URL for a
form.ioproject, and the alternative was hand-editing a file the skills forbid touching). A KEPT deployment is stale data rather than the caller's answer, so it is now dropped and the derived value used, as the reader already does; only a value the caller supplied is refused, because only that one is theirs to correct. The write says which stale value it replaced. - A mistyped Project URL exits
1, not2.htps://…or a bare host is the user typing the wrong thing, and the remedy is to re-ask — the same reasoning that already puts the API-root and Open Source refusals on1. As a2it told every skill to relay and stop, abandoning the round that was about to supply the right value. A malformed URL read from a record stays a2, since no answer from the user repairs a broken record. - The tolerated unreadable map has its own sentence. It re-emitted the fatal error verbatim — "repair or delete that file FIRST, nothing below will run until it is" — on a path where nothing needs repairing, naming a write that cannot run while the file is unreadable and would not take effect if it could, because the committed file governs.
- A clause pointing at a remedy is gated with that remedy. "The write below records the pair for this directory" survived into the one message that omits every remedy and warns against recording anything under that directory.
- A stranded deployment is accounted for on the unconfigured answer. That status hardcoded empty precedence lists, so a mapped deployment with no project beside it was reported nowhere — and that status's own remedy replaces the entry holding it.
- A discarded environment record names every variable it loses. The API-root note named
FORMIO_PROJECT_URLwhere its Open Source twin named both, so aFORMIO_BASE_URLbeside a rejected project URL vanished silently. - A refusal states the reason it was given. "It was recorded for a different project" was the fallback for every case, including an entry that names no project at all — a false fact about a record the user is looking at. The planner now says which of the reasons applies.
- Both writers report the
formio.jsonfiles they walked past, asproject_getalready did.
The refusal that defers to another record now judges the pair it would create. A
--base-url-only call whose project lives in a committedformio.jsonor the environment returned "record it where the project is" without ever asking whether that pair is one the resolver accepts — the single path around the chokepoint, and the one whose answer a human carries out by hand. It told users to write a committed pair that made every later call for that directory exit2, and printed a command the same binary refuses. The offered deployment is now classified against the project the other record holds, before any remedy is named.Smaller fixes in the same family: the "pass it explicitly to confirm it" invitation is suppressed where the value was dropped because the pair rule rejects it (following it was refused every time);
project_set's refusals now carry the walk's notes, as its successes and the CLI's failures already did; a refusal that names the server's own fallback directory now warns that it is not the caller's, asproject_getdoes for the same state; and the half-configured report names the directory it is about in its own prose rather than only in a note.The termination harness was audited and repaired. It was added to close this defect class and was itself letting two members of the class through. Its edit reader matched the first
formio.jsonpath and quoted key anywhere in the output — in practice the leading notes, naming a file the walk had passed over — so it wrote a fabricated project into a file the server disowns and counted the change as progress; it is now anchored to the sentence that instructs the edit, and writes the value that sentence names. It also tolerated a refused first step so long as some later message named something that worked, which is precisely the loop it exists to forbid: a command the server prints with every value already known must now be accepted, while one carrying a placeholder may still take a second round for a value the message could not have known. Six rows were added for states it could not reach — read-time pair-rule refusals, a dropped deployment that cannot serve its project, and deployments offered for a project another record holds. The two remedy defects above were found by the repaired harness rather than by reading.The hosted cloud is refused as a customer project's deployment.
https://api.form.ioserves only the projects on it, so it is never the Base URL of a project on another domain — the single wrong answer a base-URL interview is likeliest to receive, and the one three separate documents call out in prose ("the guess this table exists to prevent"). Nothing enforced it: the pair was accepted, recorded, and reportedok, pointing the portal login and the token cache at Form.io's cloud for a self-hosted project.classifyPairnow carries the converse of the hosted rule, so the writers refuse it and the reader ignores it and derives, naming the value it set aside.The deferral block judges only what it should, and every verdict it can meet. Introduced in the previous release, it classified the deployment offered for a project held elsewhere — but it ran even when this directory's mapping held a project of its own, so an unrelated
FORMIO_PROJECT_URLrefused a write that amends the mapping's own pair, andproject_get's own remedy became a callproject_setrejects. It also handled two of the three non-okverdicts, so an API-root project fell through and produced a command the writer refuses and a committed edit that leaves the file unusable. It now runs only where this record has no project, and covers every verdict.A refusal no longer names the directory it warns against. With no
cwdargument the writer's refusals told the caller to re-call with the server's own spawn directory, one sentence before warning never to record anything there. They now name the user's own directory, asproject_getalready did for the same state.Each stranded value carries its own consequence. "The write below replaces that entry" was appended to a joined list that can hold either record — true of a mapping entry, false of an environment variable no write can touch, which was reported as replaced and then reported again, unchanged, on every later call.
The termination harness was attacked again and repaired again. Its PROGRESS check asked only whether the directory resolved differently, so a remedy that re-points it at another project — overwriting the user's configuration and never recording what they asked for — counted as success; it now requires the resolved project to be unchanged unless the call itself named a new one. Its placeholder tolerance is granted only when the refusal is genuinely ABOUT the value the user supplied, rather than whenever a placeholder happened to be present. Nine rows were added for states it could not reach, and a second table now drives the TOOL surface, which no row had ever touched and where two of this round's defects lived. One of the new checks was itself silently vacuous on first write — it read
stdoutwhile the half-configured report prints tostderr— which mutation testing caught before it shipped.Wherever a deployment is derivable, the derivation is the definition. That rule was enforced for a hosted-cloud project and for nothing else, so the OTHER derivable shape — a project addressed as a sub-directory — accepted any deployment at all:
https://forms.mysite.com/one/twopaired withhttps://forms.mysite.com, or with an unrelated host entirely, was written, resolved, and reportedokwith no note. Writer and reader agreed on it, which is why no cross-record invariant could see it.classifyPairnow refuses any recorded deployment that contradicts a derivable one; the shape that derives nothing is untouched, because there the caller's value is the only one there is.Hosts are compared in one form. A trailing root dot —
https://api.form.io.— is the same host, resolves identically, and compared exactly it walked past every rule in the module: the hosted cloud's API root could be recorded as a project, and as a customer project's deployment, evading both refusals written for it. Andform.ioitself carries no project sub-domain, so the apex and the site and portal hosts are no longer accepted as projects that resolve tohttps://api.form.io.The deferral decides its target once. The pair check and the refusal branch each worked out which project the call would defer to, and disagreed: the check asked only where this record had no project of its own, while the committed branch fires whenever the committed file names a different one. A directory holding both a mapping project and a committed project therefore skipped the check and was told to hand-write a pair that makes every later call fail. One expression now serves both.
The termination harness, third pass. Its honesty assertion accepted any message containing the substring
https— every message on this surface carries an example URL, so a refusal naming no value from the caller's state passed; it now requires a whole value, and the malformed-URL refusal names the value as typed so it can. It judged only the FIRST command a message names, so a second remedy the writer refuses rode along unchecked; every fully-specified command is now run. A row was added for the state no sweep had covered — two records each holding a project — which is the state the deferral defect lived in.An Open Source install mounted at a sub-path is refused, like one at the origin. The collapse question — is the deployment the same URL as the project? — was asked after the derivation question, and for any project URL carrying a path a derived deployment is always a strictly shorter parent, so the two could never be equal there.
https://forms.mysite.com/formiorecorded as its own deployment was therefore diagnosed as a derivation mismatch, whose remedy says "record the project URL alone" — and that succeeds, storing a host root that serves nothing. The question is now asked of the recorded values, before derivation is consulted.A hosted project URL is a sub-domain and nothing more.
https://examples.form.io/myprojectwas accepted, derivedhttps://api.form.io, and then addressed every request at.../myproject/...— the unexplained-404 failure the API-root refusal exists to prevent, reached from the other side. And the form.io hosts that are not projects — the apex,www,portal— were excluded from derivation but never refused, so they were still recorded as the active project and then described to the user as "a project URL that carries no path on a customer domain", which is false of a form.io host and invites a Base URL that is accepted and equally wrong. Both are nownot-a-hosted-project, with their own explanation.The verdict table is pinned as a table. Ordering decides which diagnosis a user reads and which half of the pair is faulted, and the faulted half decides whether a directory fails outright or resolves on a derived value — so each reordering across the last three rounds fixed one case and silently moved another. Fifteen shapes are now asserted together, including the two cases where a deployment-half verdict must outrank the collapse (a hosted project paired with itself is a wrong deployment, never an Open Source install) and the case where it must not (a customer project paired with itself at a sub-path).
The revisions license gate names the write that reaches the record. It was the one message on this surface that hardcoded the mapping's own
--base-urlcall rather than going throughbaseUrlWriteCommand, so for a project held by a committedformio.jsonor by the environment it named a command the writer refuses.A directory moving from the hosted cloud to a self-hosted deployment re-points cleanly. Recording a new Project URL on the customer's own domain overrides the SaaS pair exactly as a fresh entry would: where the new project derives its deployment the write simply succeeds and the old
https://api.form.iois gone, and where it does not, the refusal asks for the Base URL. That refusal used to add "If it IS the right deployment, pass it explicitly to confirm it" about the strandedhttps://api.form.io— an answer refused every time it is given, because the hosted cloud cannot serve a project on another domain. Whether re-supplying a stranded deployment could work is now asked of the pair rule rather than inferred from how the value came to be stranded.The project half is judged before anything about deployments. Asked only once a deployment existed, the project-URL shapes that derive nothing never reached the chokepoint on the write path at all — so
https://form.io,https://www.form.ioandhttps://portal.form.iowere answered by the base-URL branch with "a project URL that carries no path on a customer domain", which is false of a form.io host, and the command that message printed was then refused naming nothing further, dead-ending the interview. Reachable straight from the documented preflight, by pasting the marketing site when asked for a Project URL.project_setreports the project that is ACTIVE. Its structuredprojectUrlis documented as "now active for that directory" but carried the value just written, so an agent branching on it believed it had re-pointed a directory a committedformio.jsonstill governs — the prose said otherwise, andproject_get's own schema tells agents to act on fields rather than parse prose. It now reports what resolves, withrecordedProjectUrlnaming what was written when the two differ.A trailing root dot is normalized where URLs enter.
api.form.io.andapi.form.ioare the same host — legal, identically resolved, invisible to a user — and were being collapsed inside individual rules, which fixed those rules while every other comparison still saw two hosts. It is now removed innormalizeHttpUrl, so the pair rule, the token-cache key and record-to-record equality all see one spelling. The scheme is deliberately left alone:httpandhttpsto one host are different endpoints, and one of them carries credentials in plaintext.The pairing invariant is asserted on the VALUE, not on the label beside it. Every test for "halves are never combined across records" checked
sources.baseUrl, the enum describing where a value came from — and a description can stay right while the answer goes wrong. Making the resolver fall back to a losing record's deployment, the exact violation this design exists to prevent, left that enum readingunresolvedand passed all 975 tests. The new assertions readconfig.baseUrlandconfig.projectUrlstraight offresolveProjectConfig— the function every project-scoped handler calls — including a sweep asserting that whatever is handed to a tool always passesclassifyPair. Six of them fail on that mutation now.The auth path's URLs are pinned to the deployment they are supposed to come from. Every fixture on that path paired
https://formio.invalid/examplewithhttps://formio.invalid— the one shape where a project's origin, its derived parent path and its recorded deployment all coincide. So no assertion could tell "built from the deployment" from "built from the project's own host", and building the portal-login URL,${baseUrl}/current, or the Security Module probe from the wrong one passed the entire suite. That is the "a deployment you do not use" failure the refuse-to-guess-a-base-URL rule exists to prevent, reached from inside: the login page and the token it returns would belong to a server the user never configured. The shared fixture now uses a project whose deployment is neither its origin nor itself, thirty co-varying local fixtures were swept with it, and the seven assertions that turned out to be reading the wrong value were corrected. The login-form cache key — which must include the project, since two of its three candidates are project-scoped and every hosted project shares one base URL — and the license probe now have tests of their own.project_setreports a pair from ONE record. Its structuredprojectUrlis documented as the project now ACTIVE for the directory but carried the value just written, so an agent branching on it believed it had re-pointed a directory a committedformio.jsonstill governs. Reporting the governing project beside the deployment just written would have been worse — a pair belonging to no record, and oneclassifyPairrejects, which the skills read asappUrl/apiUrl— so both halves now come from whichever record governs. What the call WROTE is not echoed back as a field: it is the argument the caller just passed, and the message names it in the one case it differs from what resolves. That message no longer opens with "Active project set to …" about a project a committed file outranks; it says "Recorded …" and then names what is actually active.The preflight prose no longer promises a call that does not exist. Eleven skill documents said to "make the
project_setcall the message names" onbase-url-unresolved. For a project a committedformio.jsonholds, the report names a FILE EDIT, carries no structuredremedy, and a composedproject_setcall is refused — so the instruction sent an agent into a hard error. Each now states both cases, andformio-angular-resources' unhedged "it names a tool call, never a shell command" is corrected the same way.project_setasks the resolver what resolves, instead of working it out again. Deciding it locally gave the writer a second, simpler model of precedence, and it was wrong two ways: it compared only the PROJECT halves, so a committedformio.jsonnaming the same project as the write left the just-written deployment reported as active while that record supplies none — the caller is told the repair landed and the next authenticated call fails inrequireBaseUrl; and it echoed the committed file's recorded deployment without the pair rule, so it could report a pairclassifyPairrefuses while the resolver derived a different one. One source of truth removes both, and writer and reader now agree exactly.The prose drift guard was skipping the band that matters. Its length pre-filter discarded any pair whose lengths differed by more than the similarity threshold — but similarity is
2·LCS/(|a|+|b|), so the real bound isT/(2−T)= 0.818, not 0.9, and everything between was thrown away as though it were nothing alike. Adding or removing a sentence from ONE copy of a shared paragraph lands squarely in that band, which is the likeliest way this prose actually drifts. Corrected, the guard immediately found real drift it had been hiding. It also walked only one directory deep, so the nestedformio-angular-resourcessub-skill — which carries ten byte-identical copies of the same preflight — was compared with nothing and could drift freely. Both bounds now live inprose-similarity.tswith their derivations and have tests of their own, because an optimisation that decides when to skip work is exactly the part that can be wrong while everything still passes.Dead surface removed.
ProjectSetOptions.baseUrl— an injection seam the writer stopped reading when records became whole pairs — is deleted, along with the test fixtures that fed it as though it configured anything. The explanations both writers share — why a path-less project URL derives nothing, why a committed file is edited rather than written, what a stranded deployment means — live in onewrite-refusals.tsmodule instead of being copied between the tool and the CLI, where they had already drifted in wording.Skills:
formio-mcp-setup's "already connected, but too old" branch no longer sends a matching pin round its own loop.sync:pinsrestamps the launch blocks only when a release is cut, while skills install straight off the default branch, so between merging a tool and publishing it those blocks name the very release that lacks it — and the branch told every such user to upgrade to what they already run. It now compares the configured pin against the blocks first, and where they match it says what that state actually is (the release carryingproject_getis not published yet), edits nothing, and leaves naming a newer published version to the user.A write now reports the pair the next read resolves, and says so when the directory still cannot serve a call. Both writers asked the resolver for the active pair but kept quoting their own plan in the prose, so one
project_setresult announcedBase URL for <project> set to <value>while itsstructuredContentcarried no deployment at all and the note between them said the write did not take effect — three answers, two of them wrong, in the channel skills relay. TheProject URL:/Base URL:lines and the structured pair now come fromreportProject, the same reportproject_getreturns, sosetandgetdescribe one directory the same way; the record just written is named in prose, in the passive voice, wherever a committedformio.jsongoverns. A write that lands on disk and leaves the directory resolving no deployment is no longer a success:project_setreportsokfalse while still carrying the resolved pair andchanged,project setexits3, and both carry the reader's own message naming the file and thebaseUrlkey to add. The record is still written — it is the fallback if that file goes away — and what is being reported is the state of the directory, not of the write.A committed file the pair rule refuses no longer passes as a successful write. A
formio.jsonis checked for SHAPE where it is read and for VALIDITY only where it wins precedence, so a file holdinghttps://api.form.ioparses cleanly and fails later, inside the resolver. The writer caught that failure and described what it had written instead, returningok: truefor a pair the governing file contradicts and discarding the one fact the caller had to act on. It now surfaces. Resolution notes raised while answering the same question — a recorded Base URL set aside, aformio.jsonpassed over — reach the caller too, deduplicated against the notes the write itself collected, and they survive a throw from any stage of the command rather than vanishing on the run where the explanation matters most.The shared-prose drift guard can see a deletion. It scored two paragraphs on
2·LCS/(|a|+|b|), which charges a removed sentence twice — once for the missing characters, once for the length gap it opens — so one copy of a two-sentence shared paragraph with a sentence taken out scored0.000against the other: nothing alike. No threshold repairs that, because the number is right and the question is wrong; the guard now takes the higher of that reading andLCS/|shorter|, which asks how much of the shorter paragraph is shared and answers 1 for a copy with something removed. It immediately found two real divergences betweenformio-applicationand the other nine gated skills, invisible until now: a preflight paragraph where nine copies had gone stale against the setup skill's actual behaviour, and one whereformio-application's Step-3-specific guidance had been folded into shared text — now split into its own paragraph so the shared half stays identical.One record answers for one directory, and a broken record still answers for it. A mapping entry that EXISTS but cannot be honoured — a value that is not an http(s) URL, or an entry that is not an object of strings — was read by the writers through a path that treats it as absent, so they concluded no mapping held the project and deferred to
FORMIO_PROJECT_URL. The reader disagreed: the entry is configuration that exists, so it fails naming the file and the entry. Two models of precedence over one state, and the writer's was the dangerous one — it told the caller their project came from the environment and named the call that records that pair here, which overwrites the broken entry and discards the project URL the user had actually intended, readable nowhere else and never shown to them. Both writers now treat present-and-unusable as the record that governs, refuse a deployment-only write against it, and quote the recorded value back so the intended project can be recovered before it is replaced.A mapping is keyed by the directory, not by how the caller spelled it.
/repoand/repo/are the same folder and were two independent records, so configuring a directory through one spelling left the other reporting that nothing was configured for the directory just configured — and a caller whose cwd sometimes carries a trailing slash silently maintained two. The committed layer was never exposed, because the upward walk resolves its path; the map key now resolves too.The drift guard can see a deletion, a reordering, and its own bounds. Scoring paragraphs on
2·LCS/(|a|+|b|)charges a removed sentence twice, so a copy keeping two of five sentences scored0.000against the original — nothing alike, for the largest drift possible. Taking the higher of that andLCS/|shorter|fixes the score, but the cheap word-overlap prefilter that decides what is measured at all is Jaccard, which divides by the union and so has the same directional blindness: that copy scored0.383there and was discarded before the comparison ran, under a floor justified by a margin measured only against additions. Both readings now have a bound that fits them — Jaccard for a copy grown by addition, five-word-shingle containment for one shrunk by deletion, and neither subsumes the other, since drift where both copies changed leaves neither containing the other. Word overlap at or above0.95with character similarity below the threshold is now reported as its own class: the same sentences rearranged, which both readings of the LCS score far below the threshold and which for an inverted "X, not Y" clause is a different instruction. The detector moved out of the test that reads the skill library so it can be exercised against paragraphs chosen to break it, rather than only against a corpus that happens to be clean, and every bound is pinned from the side that hides drift.A refusal now prints a remedy that runs. The unusable-entry refusal asked for a Project URL and a Base URL together; both derivable project shapes refuse that pair, and the Project URL alone succeeds and derives its own deployment — so the one command the message named was the one that could not work, and obeying it on the tool path meant asking the user for a Base URL before the server had said it needed one, which this server's own instructions forbid. The reader's message for the identical state already named the working repair. The property that runs a printed remedy and requires the directory to resolve afterwards now covers what the WRITER prints, not only the reader; it was reachable only from
project getbefore, which is why a refusal reachable only fromproject setslipped past it.A value recorded in the mapping is no longer described as an environment variable. The refusal built its explanation with the name
FORMIO_PROJECT_URLfor a value read out of~/.formio/projects.json, producing "Ignoring FORMIO_PROJECT_URL: …" about a record on disk — the conflation this repository's terminology rule forbids in skill prose and had no equivalent for in the server's own messages, and doubly wrong because the value is not being ignored, it is the reason for the refusal. It names the directory whose record holds it instead.A mapping key an earlier release wrote unnormalized still resolves. Normalizing only the keys this release writes stranded every mapping already on disk whose cwd carried a trailing slash: the directory kept a visible entry and reported that nothing was configured, and the next write landed on the normalized twin and left a dead key beside a live one. Keys are normalized as the file is loaded, which makes the migration invisible and idempotent.
The drift detector's "two different skills" test is symmetric. Asked in one direction, it was order-dependent: where one carrier set is a strict subset of the other — ten skills sharing a paragraph and one carrying a drifted copy — whether the drift was reported came down to which paragraph the map happened to build first, and the alphabetically-first skill's variant is the one built first. The question is whether two paragraphs are carried by different sets of skills, which is symmetric.
And the writer builds the reader's report from the reader's config. A write that cannot leave the directory serviceable appends
project_get's own report; built from the environment's project without its deployment, that report could describe the same directory differently in its shadowed and unpaired lines from the oneproject_getgives — the disagreement asking the reader was meant to end. - Re-recording a hosted project whose stored deployment is foreign no longer loops. The stored value was inherited, rejected, and the refusal said "call project_set again with projectUrl alone" — exactly what had been called, with both escapes closed (the schema forbids passing a Base URL for a
Patch Changes
-
a6e9632:
formio-angularnow pins one absoluteworkspaceRoot, captured in Pre-flight — before the first file is read — from the handoffworkspacePathor from the directory the user invoked the skill from, and every later phase targets that path. Pre-flight is where the capture belongs because Pre-flight is what inspects the tree: readingangular.json,src/app/config.ts, and thetemplate.md/template.jsonpair against "the working-directory root" declares the wrong tree empty, and SETUP then stashes that drifted directory as the root. Each inspection now states its path underworkspaceRoot, SETUP reuses the captured string instead of asking the shell again, and a shell that cannot be accounted for — acdearlier in the turn, apwdnaming somewhere the session did not start — is confirmed with the user before anything is read or written. BOOTSTRAP no longer describes the scaffolding target as "the cwd": theangular-new-apphandoff passes the absolute root, the@angular/clifallback and the npm installs state their directory, and Step 4 verifiesangular.jsonlanded at that root instead of wherever the shell happens to be. The parent skill's Stance adds the reason — shell working directories persist between commands, so onecdretargets every relative path after it, and walking into a skill's own directory to read its files is how an entireng newtree ends up inside the user's agent configuration.The fix now reaches the phases that write files.
CONFIG.md,AUTH.md, and the Resources sub-skill'sapp-integration.mdnamed every target relatively —src/app/config.ts,src/app/auth/auth.module.ts,src/app/app.html— which is the same failure one phase later: BOOTSTRAP's own commands are writtencd "<workspaceRoot>" && <command>, and thatcddoes not carry into the next phase, so an agent arriving at CONFIG with a drifted shell reads the wrong tree, finds noconfig.ts, and writes the whole Form.io wiring there. Each of those documents now states that its paths hang offworkspaceRoot, and each skip-detection inspects the workspace at that path.BOOTSTRAP.mdalso stops crediting the capture to SETUP:SKILL.mdandSETUP.mdare emphatic that Pre-flight captures it and SETUP must not ask the shell again, and "the absolute path SETUP captured" invited the re-derivationSETUP.mdspends a paragraph forbidding. -
a6e9632:
formio-mcp-setupnow writes the MCP configuration for the client it is actually running in, instead of writing all four every time. The host is established from the agent's own identity, then from one question naming the four clients plus "not sure"; only when neither answers does it fall back to writing.mcp.json,.cursor/mcp.json,.vscode/mcp.json, and.codex/config.tomltogether. A workspace's existing.vscode/,.cursor/, or.claude/directory is explicitly ruled out as a signal — it says somebody once opened the workspace in that editor, not what is running now. Every other skill's preflight paragraph was updated to match.
0.11.0
Minor Changes
-
de6a7a8: Make the Project URL the single piece of configuration, resolve it by scope, and let the server own the guidance for it.
Configuring a project used to mean answering two questions in several places. The Project URL and the Base URL were collected together at install time and again per directory; the base URL silently defaulted to
https://api.form.iowhether or not that could be right; the environment outranked the per-directory mapping for one URL and lost to it for the other; and the guidance for choosing either value was duplicated across five skill documents that could each drift. This release reduces all of it to one value, one order, and one owner.One value to supply
The Project URL is the only value a user is asked for. The Base URL is derived from it wherever it can be:
- a project on a
form.iohost deriveshttps://api.form.io; - a project addressed as a sub-directory derives its parent path —
https://forms.mysite.com/one/twoderiveshttps://forms.mysite.com/one, not the bare origin, because a deployment may itself be mounted at a sub-path; - a path-less project URL on a customer domain derives nothing, because its deployment is a sibling sub-domain that nothing in the project URL names.
Guidance that said a base URL never carries a path is corrected: a sub-path-mounted deployment is legitimate, and the server must not publish a rule its own derivation breaks.
BREAKING (runtime behavior, narrowly): the base URL is never defaulted. In the third shape above it stays unresolved, and the first call that authenticates with a JWT fails naming
project set --base-url, theformio.jsonbaseUrlkey, and the project it applies to. Previously that setup silently attempted a portal login againsthttps://api.form.ioand keyed its token cache there — a setup that could not authenticate anyway, so this converts a late, opaque auth failure into an early, actionable one. Resolution itself still succeeds with the value absent, and API-key deployments never read it, so they are unaffected. Nothing else changes: the hosted cloud and sub-directory routing both derive as before, with the reported source nowderivedrather thandefault.sources.baseUrlandproject get'sSource:line change strings for that case.FORMIO_DEFAULT_PROJECT_URLis removed. It existed only becauseFORMIO_PROJECT_URLused to pin the server, so an install-time prompt wired to it would defeat every later mapping. With the environment now the weakest source (below),FORMIO_PROJECT_URLalready suggests without pinning — exactly what the offering variable was invented to guarantee. It never took part in resolution, so nothing resolves differently; it simply no longer appears in the resolution error,project get, the server instructions, or the registry environment list. Migration: remove it wherever it is set. Where an install-time answer is the only route — the.mcpbdesktop bundle — setFORMIO_PROJECT_URLinstead; the bundle now does exactly that.One resolution order, by scope
BREAKING: resolution is ordered narrowest scope first, and identically for both URLs — a committed
formio.json, then the per-directory mapping in~/.formio/projects.json, then the environment. Previously the project URL resolved environment-first while the base URL already resolved mapping-first, so one pair resolved in two directions.FORMIO_PROJECT_URLis therefore no longer a pin: a committed file or a mapping overrides it, andproject_setcan redirect a directory whose environment names a different project. A launch that relied on the old precedence changes target if — and only if — its checkout carries aformio.jsonor its working directory has a mapping. The migration is to remove whichever source contradicts the intended target; a deployment that must resolve one project deterministically should supply only the source it wants used.A committed
formio.jsonis the new versionable source. It holdsprojectUrland optionallybaseUrl, and is found by walking up from the working directory, taking the first file and never ascending past a directory containing.git. The nearest file wins, so a monorepo can point two application folders at two projects. Unlike the machine-local map, it travels with the code: it survives a clone and is visible in review. Write it withproject set --scope repoor theproject_settool'sscope: "repo", which records it in the application's own folder rather than an ancestor — a file placed higher governs every unrelated project beneath it.formio-angularnow writes one into the workspace it configures, so a clone resolves the project itsconfig.tswas generated for.Relatedly, an unreadable
~/.formio/projects.jsonis no longer skipped for a launch that setsFORMIO_PROJECT_URL. Skipping it was safe only while the map ranked below the environment; now that it ranks above, reading past a file that cannot be parsed could resolve a value the unreadable entry would have overridden. It is still tolerated when a committed file supplies both URLs, because nothing is left for the map to decide.The server owns the guidance; the skills relay it
Configuration errors are self-sufficient and staged. Each names the exact remedy command in both vocabularies — the MCP tool and the runnable shell command — so an agent that never read the server's instructions can still act. They arrive one at a time: no project URL resolves → an error asking for the Project URL alone, describing what one is with an example per deployment kind; a project URL resolves but its base URL cannot be determined → a separate error naming that project URL and asking for the deployment alone. Fixing the first surfaces the second instead of presenting a compound failure.
project set --project-urlis optional once a directory has a project mapped, which is what makes the base-URL remedy runnable:project set --base-url <url>no longer fails for a project URL the directory already has. Either flag alone is a valid partial update; with nothing mapped,--project-urlis still required.project getis the single read surface. It prints both URLs, names the winning source for each — including a committed file by absolute path — reports any source it shadowed, and exits0/1/2so a caller can tell "nothing recorded here" from "this command could not answer". Every skill that calls Form.io tools now runs it before its first deployment-touching call and relays whatever it says, branching on exit1(interview) versus exit2(do not interview — an unreadable map, a brokenformio.json, a malformed URL, all of which aproject setwould fail on for the same unreported reason).formio-mcp-setupprobes first and interviews only on failure.formio-resource-planneris the one exemption: it calls no MCP tool, and its Phase B emission now reads as the menu it prints rather than as calls it makes, naming who owns the gate for each option.Skills stop carrying URL wording. Deleted from the library and left to the server: the three-valid-shapes enumeration, the plain-language descriptions and example values, the validation rules, the Base-URL derivation table, and the per-skill exit-code tables.
formio-application/DEPLOYMENT.mdis deleted outright — once the wording moves, what remains is four lines that belong in the preflight every skill now carries. With resolution in the preflight there is no Deployment step left to order, soformio-applicationdrops to four steps: Intent → Plan → Import → Framework. (Breaking for the spec and its assertions, not for users.)No client prompts for anything at install time. The Claude Code and Cursor manifests launch the server with
commandandargsalone — nouserConfig, novariables, noenv. An install-time answer is the wrong scope for both values: a Form.io project is one-to-one with the application built against it, so an answer typed once is right for one directory and wrong for the next, and a global base URL silently satisfied every project including ones on another deployment. The.mcpbdesktop bundle keeps its prompts deliberately — a desktop host has no working directory to map and no repository to commit into — and its project prompt setsFORMIO_PROJECT_URL, which now suggests without pinning.One name per job, enforced
Three uses were conflated under a single spelling. Each now has one:
Use Spelling A substitution slot in an endpoint or example {projectUrl}/{baseUrl}— single bracesA value passed between phases or skills prose, or a projectUrl/baseUrlfieldThe environment variable FORMIO_PROJECT_URL/FORMIO_BASE_URL, only where the subject is the environmentSpelling a slot or a handoff value with an
FORMIO_*name told an agent to read an environment variable in order to build a URL — a different and wrong action, and one that finds nothing, because no shipped manifest sets an environment at all. 234 endpoint roots across theformio-apireferences were renamed, plus 24 slots ({{FORMIO_PROJECT_URL}},$FORMIO_PROJECT_URL,<FORMIO_BASE_URL>,YOUR_FORMIO_BASE_URL,{FORMIO_PROJECT_URL}) and 24 handoff values across 18 skill documents. Single braces keep a slot distinct from Postman's{{baseUrl}}, which stays disallowed outside code spans.The rule is now checked rather than described.
api-skills-validationhad specified avalidateLibrarysuite that no longer existed, so its rules — this one included — were prose nothing ran. The validator is rebuilt in@formio/skill-testsand fails the run on a regression, covering the terminology rule, resolved Postman placeholders, the required reference layout, the canonical portal-login auth paragraph, legacy-auth tokens, PDF proxy scope, example-value suffixes, and theformio-sdkimport rules.@formio/coreis not banned outright there: the SDK skill documents a named set of helpers@formio/jsdoes not re-export, and importing those is the fallback it teaches, so only a default, namespace, or unsanctioned named import fails.Two smaller behavior changes
A global
FORMIO_BASE_URLno longer overwrites a derivable one.project_setandformio-mcp project setfall back to the environment's base URL only for a project URL that derives no deployment of its own. Previously, setting a project onhttps://forms.mysite.com/myprojectwithFORMIO_BASE_URL=https://api.form.ioexported wrote that global into the mapping, where it outranked derivation for that directory permanently and pointed the portal login at a deployment the user does not use. Directories already mapped that way keep what is recorded; pass--base-urlto change one deliberately.smithery.yamlstopped defaulting the same value into every install for the same reason.project getreports more. Shadowed base URLs are reported alongside shadowed project URLs, from separately tracked candidate lists — the two halves resolve independently, so one shared list credited a shadowed deployment to whichever layer supplied the project. And a note about an ignored unusableFORMIO_BASE_URLnow travels with the unresolved-base-URL failure it caused instead of being dropped. - a project on a
Patch Changes
-
a64e445: Give generated Angular apps a page layout, and fix four instructions that produced an app which does not build or does not resolve.
The app shell now owns the page layout, and one phase owns the shell. Most routed surfaces in a generated app are library components mounted by
FormioResourceRoutes()andFormioAuthRoutes()— create, edit, delete, index, login, register — and generated code never authors their templates, so it cannot wrap them. Horizontal gutters, max content width, and top spacing therefore belong on the one element every route passes through: the shell's wrapper around<router-outlet>. Without it every library-rendered page sat flush against the viewport edges, andng buildand unit tests both pass on that layout, so nothing caught it.formio-angular's AUTH phase is now the single writer of that wrapper, its skip-if-already-wired gate fails when the wrapper is absent (so an app generated before this change gets one on a re-run rather than being skipped as already wired), the no-template.jsonfallback still writes it, and the Resources sub-skill's Phase B closing check signs in and loads a library-rendered route before reporting done. Page templates are forbidden from adding their own page-level wrapper: that pads the routes you control and leaves the ones you do not still unpadded, hiding the gap instead of closing it.The navbar shares the content's container, not just its padding. A full-bleed
<nav>padded to the same value as acontainer-xxl<main>aligns with the content only below the container's max width and drifts apart above it, so the brand visibly misses the content at a 1400px viewport. The navbar now carries an innercontainer-xxl px-3 px-md-4— Bootstrap's own.navbar > .container-*pattern, some-auto/ms-autostill behave — and ships the emptynavbar-nav me-autolist that the Resources sub-skill fills with one link per browsable resource. The design language for all of this is read off what is installed inangular.jsonrather than a selection that has not happened yet; when the Resources interview later picks a different one, it re-expresses the wrapper and the navbar container as part of swapping the stylesheet, because Bootstrap classes left behind after Bootstrap's stylesheet is removed mean no gutters at all.FormioAuthConfig.login.form/.register.formare URL paths. The reference documentedlogin: { form: 'userLogin' }, a form machine name.@formio/angular'sFormioAuthServicebuilds the form URL asappConfig.appUrl + '/' + config.login.formand hands it to the renderer as[src], and a Form.io form URL resolves bypath, never byname— so a machine name 404s on sign-in. The documented values are nowuser/loginanduser/register, matching both the library's own defaults and thepaththe planner writes intotemplate.json.Three snippet fixes that broke the build. The shell template interpolated a brand property the root component never declared, which under
strictTemplates(theng newdefault) isNG9, not an empty string — both the root component and the home component now declare it. The component stylesheet extension is no longer asserted to be.scss: the CLI emitsapp.cssunless the workspace was scaffolded with--style=scss, and pointingstyleUrlat a file that does not exist fails the build. And the app integration reference'sAppModulesnippet imported./app-routing.modulewhile the rest of the skill generatesapp-routing-module.ts.Also aligned across the Angular skill: Angular 20+ file and class naming (
app.ts/app.html/App,home/home.ts/Home) with the legacy names kept parenthesized; Bootstrap Icons everywhere instead of the FontAwesome install that contradicted what the skill actually wires, includingFormio.icons = 'bi';/auth/registerfor the first-user sign-up link, which was documented as the dead/register; andcontainer-fluidremoved from the design brief's first-reach utilities, since it drops the max content width the shell contract requires.
0.10.0
Minor Changes
-
94896a6: Resolve the medium-and-above skill-scanner findings across the library.
Every documented launch of the MCP server is pinned —
npx -y @formio/mcp@<version>in the three client manifests, the install docs, and every skill that prints the command — so an install runs the server the release was tested against instead of whatever the registry serves at that moment.pnpm sync:pinsstamps the pin frompackages/mcp-server/package.jsoninsidechangeset:version, so the Version Packages PR already carries the version about to publish, andpnpm changeset:followadds the plugin bump that a server-only release needs to carry the new pin to npm. Three suites fail when either goes stale.The server's own browser-login page stops loading an unpinnable vendor bundle. It fetched
https://cdn.form.io/js/5.2.2/formio.full.min.js, a host that serves whatever it decides to serve for that path with no integrity check available; the page now loads@formio/js@5.5.1, Bootstrap 5.3.8, and Bootstrap Icons 1.13.1 from jsDelivr with anintegrityhash on every tag, and the renderer bundle narrowed toformio.form.min.js— the UMD globalFormio.createFormneeds and nothing more. This changeset releases@formio/mcpfor that fix, which also republishes the plugin carrying the new pin.formio-angularno longer fetches unpinnedpackage.jsonfiles from a CDN to decide what it installs: version resolution goes throughnpm viewagainst the registry the install itself uses. Installing the Angular team's skill library is now an offer with the exact command shown, and declining it falls back to the pinned Angular CLI rather than dead-ending.The release tooling that enforces all of the above is itself checked.
pnpm sync:pinsrecognises every runner spelling that resolves the registry at launch time (npx,npm exec,pnpm dlx, with or without a yes flag, plus global installs) so none ships floating, and its manifest pattern is anchored to the args array that launches the server — an unanchored quoted package name also matched"dependencies"entries, quotedpnpm --filterarguments, and the registry's"identifier", and stamping a version into any of those corrupted the file and then failed the plugin build until the corruption was committed.pnpm check:releasesskips thechangeset-release/*branch, the one PR that consumes every changeset while rewriting the versions and pins it guards. And the login page's SRI digests are now derived rather than typed:pnpm sync:srifetches each pinned asset and writes the digest of what the CDN served, withlogin-asset-integrity.test.tsverifying it whenever the network is reachable — a stale digest passed every shape assertion and then blocked the renderer, hanging the login flow on a blank page.formio-angularkeeps a bounded offline path for a host that cannot reach the npm registry: an@formio/angularmanifest already innode_modules, otherwise asking the user for the version and Angular major, with the source named in the approval summary either way. Never a CDN, and never a guessed major.formio-actionsdrops the save action'stransformsetting (a JavaScript string the server executes) and documents the build-time/runtime split explicitly: the skill writes project configuration and stops, the actions it configures run later inside the Form.io server, and the toolset exposes no submission-read tool — so nothing a submitter types is ever input to the skill. Its guidance is now framed as what the configuration decides about runtime handling, which is the only thing a build-time skill controls.formio-formgets the same framing for the code it writes around a rendered form, and states that the JS-string forms of field logic — thejavascripttrigger, thevalueaction,customAction— are the user's to write, never the agent's to generate.The same build-time/runtime split resolves a contradiction that ran through the whole library. Every skill's preflight said "do not write code that [makes direct HTTP requests against a deployment]", which forbade the applications
formio-angularandformio-formexist to build; the ban is now scoped to build-time work, with the app's runtime API calls named as expected and legitimate.formio-api's five runtime-scope references — submissions, auth, reports, access control, custom users — no longer say "use the HTTP endpoint directly" with no actor named: they state that the caller is the finished application, using the end user's own token, and that reading end users' submissions is no part of configuring a project.
0.9.1
Patch Changes
-
f6f2cd6: Stop the skill library from emitting
persistent: falseon credential fields, which produced user records with no email or password and no way to log in.The
task-managerplanner example was the source. ItsuserLoginform shipped bothemailandpasswordwithpersistent: false, while thecomplex-crm-transitiveexample and the canonical component snippets inreferences/template-json.mdusedpersistent: truefor the same two fields. An agent reading the library got contradictory guidance depending on which example it landed on, and carrying thefalseblock onto a form that writes into theuserResource — a registration form, or a combined login/register form — meant the server stripped both fields before the save. The user row was created without credentials, so login was permanently impossible. Both fields are nowpersistent: truein that example and in the matching eval fixture, aligning with Form.io's defaultuserLoginform.formio-authno longer teachespersistent: falseas a way to avoid storing credentials.references/login-forms.mdandreferences/resource-auth.mdboth specifiedpersistent: falseon the login form'spassword. A login form stores nothing because it carries no Save Submission Action — only a Login Action — not because ofpersistent;persistent: falseis a submission-stripping flag whose only effect is to destroy data on forms that do save. Both references now specifypersistent: trueplusprotected: true, andlogin-forms.mdcarries the explicit prohibition and explains the failure mode.The rule is now stated where the planner and schema references will hit it.
formio-resource-planner'sreferences/planning-rules.mdgains a "Credential fields are alwayspersistent: true" rule covering the identifier (email,username,userId) and the secret on the user Resource and on every login and registration form, andformio-schema'sreferences/form/base-component.mddocuments the prohibition on thepersistentrow itself.
0.9.0
Minor Changes
-
a494543: Use the Form.io toolset from any coding agent, not just Claude Code.
Install it two ways, and they are alternatives rather than steps.
- A plugin install wherever the agent has a marketplace — Claude Code, Cursor, GitHub Copilot CLI, VS Code, Codex. One step, carrying the skills and the MCP server. The plugin directory ships three manifests over one
skills/tree and onemcp.json:plugin.json(Agent Plugins 1.0.0),.cursor-plugin/plugin.json, and.claude-plugin/plugin.json. Each client detects its own and ignores the rest. npx skills add formio/aifor everything else — 75+ agents, installing once into.agents/skills/with Claude Code symlinked to the same files. That installer handles skills only, so a newformio-mcp-setupskill connects the server on first use: every skill carries a preflight that checks for its tools, hands off to setup when they are missing, and is forbidden from working around the gap with raw HTTP against a Form.io deployment.
One server behaviour everywhere. Project resolution follows a single documented order —
FORMIO_PROJECT_URLfrom the environment, then the working-directory mapping in~/.formio/projects.json, then an actionable error namingproject_set.project_setis registered for every client,cwdis one schema everywhere, andFORMIO_BASE_URLalways defaults.One exit-code contract for the
projectcommand.project getexits0when it resolved,1when nothing is mapped for that directory, and2when the command ran and could not answer — so a skill can tell "nothing here yet" from "this failed", and stops interviewing on the latter. No launch carries a version range:@formio/mcpis a 0.x line, so a floor hard-coded into a shipped manifest or skill goes stale at the next release and a ceiling would freeze installed plugins on an old server. The one silent case — a pre-0.9.0 binary ignoring theprojectarguments and exiting 0 with no output — is handled by rule: empty output is never an answer, and no skill may report a mapping it did not read.Configure a project before any client connects. The
formio-mcpbin gains aprojectcommand:project set --project-url <url> --base-url <url> --cwd <path>writes the mapping through the same module theproject_settool uses, andproject get --cwd <path>prints what resolves and which source supplied it. Invoked with no arguments the bin starts the stdio server exactly as before.formio-mcp-setupuses this to capture the project during setup, so the first tool call after a reload works instead of failing.The server explains itself. It declares MCP
instructionsat initialize describing what it needs — the Project URL, and the Base URL, which builds the portal-login URL and keys the cached token and therefore must not be assumed. Used stand-alone with no skills installed, that plus the resolution error is now the whole of what an agent needs; previously neither mentioned the base URL, so a self-hosted user could silently log in against the wrong deployment.A configured default is offered, not applied.
FORMIO_DEFAULT_PROJECT_URLis surfaced as a suggestion in the instructions and the resolution error, for the agent to confirm and persist withproject_set. It takes no part in resolution.FORMIO_PROJECT_URLremains the opposite: it pins the server, andproject_setcannot redirect it.BREAKING —
FORMIO_PLUGIN_CONTEXTis removed. It gated per-directory project routing,project_setregistration, and a requiredFORMIO_BASE_URL, so all of that was unavailable outside the Claude Code plugin. Plugin behaviour is unchanged, and a pinned stand-alone launch stays pinned even with a stale mapping.BREAKING — the plugin ships no hooks. The
verify-project-urlgate matched a Claude-namespaced tool prefix and expanded${user_config.*}, so it only ever fired in Claude Code with a plugin install and was inert everywhere else, including every skills-only install. It could also deny tool calls the server resolves fine. Its behaviour is carried identically for every client by the server's instructions, the resolution error,formio-mcp-setup, and the orchestrator's Deployment step.BREAKING — no client prompts for a project URL at install time. The Cursor prompt fed its answer into
FORMIO_PROJECT_URL, which takes precedence over every per-directory mapping, so filling it in locked the server to one project and silently defeatedproject_set— contradicting the prompt's own description. Both that prompt and Claude Code's are now gone: a deployment is shared across a developer's projects, but a Form.io project is one-to-one with the application built against it, so the one folder an install-time answer is collected in is the only folder it is right for. Install asks forFORMIO_BASE_URLalone; the agent captures the Project URL in the directory it belongs to and persists both withproject_set. The.mcpbdesktop bundle keeps its optional project prompt — a desktop host has no working directory to interview in — but that answer now reaches the server asFORMIO_DEFAULT_PROJECT_URLinstead ofFORMIO_PROJECT_URL, so it is offered for confirmation rather than pinned, and anyproject_setmapping overrides it.FORMIO_DEFAULT_PROJECT_URL(a suggestion) andFORMIO_PROJECT_URL(a pin) are still read from the environment for scripted and containerized launches.BREAKING (install path, not API) — manifests reachable from a git clone launch the server with
npx -y @formio/mcprather than the bundled${CLAUDE_PLUGIN_ROOT}/server/stdio.mjs, because a clone contains no build output. Release ordering is what closes the window this opens: until@formio/mcp0.9.0 publishes,latestis 0.8.4, which registersproject_setonly under the now-removedFORMIO_PLUGIN_CONTEXT— so publish the server with or before the marketplace change rather than pinning a floor into manifests that would outlive it. The plugin tarball ships no server bundle; the build still writesdist/plugin/server/stdio.mjsfor the smoke test, and the.mcpbdesktop bundle builds its own copy..claude-plugin/marketplace.jsondeclares"source": "./plugin", which is also what makes the skills CLI discover the library.The skills read correctly in every client. Instructions name the client's structured question mechanism rather than one client's tool, and the batching rule — everything a step needs in one round, never a sequence of prompts — is stated portably. Tool availability is a capability probe rather than a tool-name prefix match.
frontend-designkeeps its name, because it is a portable Agent Skill; what went is the assumption about how it is registered and the client-specific commands for installing it.No restart boundary. The orchestrator no longer writes
.mcp.jsonand no longer halts for a reload. It writes no MCP configuration at all — a missing server routes toformio-mcp-setup— and its Deployment step resolves an existing mapping before asking, so a project is captured once and never re-requested.Browser login fails fast where there is no browser. CI, containers, and SSH sessions with no display are detected before a port is bound, with guidance to set
FORMIO_API_KEY;FORMIO_FORCE_BROWSER=1overrides the check. Previously such hosts waited out the full 15-minute timeout.Other changes: the nested Angular sub-skill moved to
formio-angular-resources/so its directory matches its name, and its description was trimmed to fit the specification's 1,024-character budget — both were Agent Skills violations that only surfaced in clients discovering skills by recursive scan. The published bundle carries only what a consumer needs: eval harnesses moved topackages/skill-tests/evals/, and a test enforces the boundary by allowlist. CI validates everySKILL.mdagainst the Agent Skills specification. - A plugin install wherever the agent has a marketplace — Claude Code, Cursor, GitHub Copilot CLI, VS Code, Codex. One step, carrying the skills and the MCP server. The plugin directory ships three manifests over one
-
cbb5d57: Harden the skill library against the risks automated skill scanners flag, and drop the two action types whose whole job is writing submissions into an external system of record.
formio-actionsno longer documentssqlconnectororgooglesheet. Configuring either is a server-administration task — it needs credentials, a target schema, and grants belonging to whoever owns that database or spreadsheet, none of which a form-configuration flow should be inventing. Both sections are gone, along with their quick-reference rows, and a new closing section states the boundary for the whole class: when a server's dynamic catalog offers an action type that copies submissions into an external system, say it is not covered and point at the Form.io administrator, rather than reading its settings offaction_type_getand configuring it anyway. The documented catalog is now six open-source types (save,login,role,email,webhook,resetpass) and five Enterprise types (oauth,group,ldap,twofalogin,twofarecoverylogin).formio-actionstreats submission data as hostile input. Every{{ data.* }}token an action interpolates is a value a submitter typed, and actions carry it off the server into email bodies, webhook URLs, and recipient lists. A new Security section covers interpolation-is-not-escaping, dynamic recipients ({{ data.managerEmail }}inemails/cc/bcchands a public form's submitter your mail transport), secrets in action settings travelling to whatever host those settings name, and the indirect prompt-injection rule: an email body, webhook payload, orsubmission.metadatavalue that an agent later reads is quoted data, never instructions and never tool selection. Webhook URL interpolation now requires a literal scheme and host, because a submitter-controlled segment can redirect the request and the Basic Auth credentials with it. The Email action'stemplatedefault no longer prints a URL that automated scanners flag as phishing, and settingtemplatenow carries the warning that the server re-fetches it at send time, so whoever controls that URL controls the markup of every email.formio-formstates that a form definition is executable code.calculateValue,validate.custom,logic, HTML component bodies, and select templates all evaluate in the page's JavaScript context, so a definition is a code-execution channel: render only definitions from a project you control, and never widensanitizeConfigto admitscript,on*, orsrcdoc.fetch.authenticateandfetch.forwardHeaderson a Data Source component now carry the warning that they attach the user's Form.io token to whatever hostfetch.urlnames — the token-exfiltration path a scanner correctly identified — so they belong only on endpoints on your own deployment.formio-formstops teaching an unpinned CDN. ESM is now the preferred inclusion mode; the CDN block is version-pinned to@formio/js@5.5.1on the npm CDN with SHA-384 Subresource Integrity hashes and the command to recompute them, and notes that the unversionedcdn.form.iobundle cannot be integrity-pinned. Example form URLs are a placeholder project rather than Form.io's public demo project, so no example depends on a host the reader does not own.formio-sdkleads its Evaluator reference with what the module does. It compiles strings into running code, so expression source must be trusted;interpolateStringemits unescaped output; andregisterEvaluatorswaps the singleton process-wide, which makes a dependency that calls it a supply-chain concern.
0.8.0
Minor Changes
- a9012a6: Adding
formio-form,formio-form-builderskills and general cleanup.
0.7.0
Minor Changes
- e620bdb: Fixed login action and email action related issues.
0.6.0
Minor Changes
- 231c3bf: Fixed issues where "admin" applications would pick "user" for Login actions instead of "admin"
0.5.0
Minor Changes
- 49c727b:
formio-angularskill: generate deterministic Angular apps under modern change detection.- The skill now always targets the latest Angular
@formio/angularsupports and pins zoneless change detection explicitly (provideZonelessChangeDetection(), nozone.js) in BOOTSTRAP Step 6, instead of force-re-addingzone.jsand inheriting the CLI's drifting default. No specific Angular version is named in the skill. - CONFIG/AUTH/app-integration keep the simple
{ provide: FormioAppConfig, useValue: AppConfig }provider — no app-level wiring change is needed because@formio/angularreads that config in theFormioModuleconstructor and configures the SDK (Formio.setBaseUrl/setProjectUrl) at bootstrap. - BOOTSTRAP notes one Form.io-specific caveat for zoneless apps (the SDK's promises resolve outside Angular's zone, so refresh views with signals/
markForCheck, notNgZone.run). - Corrected stale guidance that claimed zone-based CD was required.
- The skill now always targets the latest Angular
0.4.1
Patch Changes
- ae993dc: Fixed issues with baseURL not getting set correctly.
- 4237e6c: Check cached JWT expiry locally before use. The MCP server now decodes a cached
token's
expclaim and clears expired tokens — both from the on-disk cache and the in-process cache — before attempting any request, triggering re-auth instead of thrashing on failing calls with a known-dead token.
0.4.0
Minor Changes
- f75be94: Added authenticated route guards to the angular skill.
0.3.0
Minor Changes
- 736278e: Added better authentication indication in login page. Improved formio-angular for correct Auth module use. Encourage the use of frontend-design skill when building applications.
0.2.0
Minor Changes
- d98a326: Added formio-auth, formio-schema, form revision support, and many improvements to the skills.