I spent more than ten years writing JSF. JSF 1.2 first, then years of JSF 2 and PrimeFaces in a corporate environment, and teaching other people to do the same. We wrote a lot of our own components. That meant Java and templates in the same screen, and long afternoons working out what was happening underneath a p:dataTable.
Two bugs from that decade have stuck with me. A #{bean.saveAndClose} that no longer existed went to production. Nothing in the build reads that string, so a user found it instead of the compiler. The other was ViewExpiredException, which I would rather not think about again.
We later moved one of those applications to AngularJS, which opened a different can of worms. That's a story for another day.
I joined Vaadin in 2022. Flow looked like where I would have wanted those applications to land. It is server-side and stateful the way JSF is, but a screen is just Java: its state lives in an object on the server and an event handler is a method. There is no template language and no expression string sitting beside the code, so that stale #{bean.saveAndClose} would have been a compile error.
I wanted to know what moving a JSF application to Vaadin actually takes, and how much of it an agent can be trusted with. I needed something real, and settled on web-budget: Java EE 8 on WildFly, CDI and DeltaSpike, Apache Shiro. Fifty-three screens, and years of business rules somebody got right a long time ago.
Then I pointed a fleet of agents at it. Handing one agent the whole codebase and asking it to migrate it doesn't work, for two reasons. Fifty-three screens, with their backing beans, services and entities, don't fit in a context window. And what comes back is a plausible-looking pile of Java that somebody has to audit line by line. So the pipeline cuts the work into pieces small enough for one agent to hold and one person to check, and each piece carries its own description of what to build. Each piece is a ticket.
The rest of this is how I split the work between the model and plain scripts, and what broke. The worst of it was thirteen screens that rendered blank while the test suite stayed green.
Rewiring the service layer
The target was fixed early: Vaadin 25 Flow on Spring Boot 4, Java 21, Spring Data JPA, Spring Security. Any move off javax.* costs the Jakarta conversion, and Vaadin 25 requires Jakarta regardless.
How much an agent can safely do depends on how many pieces have to agree before a screen works, and on whether anything checks that they do. On a single-page-app target, every view needs an endpoint, a payload shape, a serialization boundary and a client-side copy of the validation rules. In Flow the view and the service are the same Java. A view calls a ported service as an ordinary method, and the compiler checks the call. Nobody has to restate the validation rules: BeanValidationBinder can read the JSR-303 annotations already on the entities. @RolesAllowed/@PermitAll and Spring Security sit next to the code they guard.
So the agents rewired the service layer and left what it does alone: @ApplicationScoped becomes @Service, DeltaSpike's EntityRepository becomes JpaRepository, CDI events become ApplicationEventPublisher, and javax.persistence becomes jakarta.persistence.
The method bodies aren't touched, because nobody can review that much code at scale. The service layer is copied into the new module and converted there, and the original project is never modified. With both applications on the same database, you can open the same screen in each and compare.

The pipeline. Phase A plans the migration and creates one issue per view; phase B resolves them one ticket at a time, each checked in a browser.
Fifty-three tickets
The first half of the pipeline is deterministic Python plus a handful of Claude Code skills. It ends with issues in a tracker.
| Purpose | Skill | Script |
|---|---|---|
| Detect the source stack: namespace, DI, persistence, security, UI toolkit | jsf-migration-assess | detect_stack.py |
| Screenshot every navigable view as a visual baseline | jsf-screenshot-tour | none, drives headless Chromium |
| Inventory the views | none | enumerate_views.py |
| Map each view to its xhtml and backing bean files | none | resolve_views.py |
| Distill each view into a behavior spec | jsf-view-analyze | analyze_entity.py, resolve_i18n.py, extract_bindings.py |
| Decide the cross-cutting choices: auth, theme, layout, i18n | migration-architecture | none, it interviews you |
| Build the dependency graph and draft the tickets | jsf-migration-issues | build_issue_graph.py |
| Create the issues in the tracker | jsf-migration-issues | create_issues.py, forgejo_issues.py |
Where the Skill column says "none", the step has one right answer and a script computes it. The other steps need judgment, and that is what the skills are for.
A behavior spec lists the fields, columns, validations, permissions, i18n keys and the service method each action calls. It goes into the ticket body when the ticket is created. An agent can implement a view from the ticket alone.
The tickets depend on each other the way the code does: a foundation issue blocks everything, a feature's port issue blocks that feature's views, and login blocks every secured view. Put "blocked by" backwards and every ticket still reads correctly while the backlog runs in reverse. I confirmed the direction with a live smoke test against Forgejo.
What to script, what to hand to a model
Stack detection, view enumeration and graph building are pure stdlib Python, with no network and no model calls. When they're wrong, they're wrong the same way every time.
That saved me early, when my enumerator reported two views in a fifty-three-view application. Its heuristic threw away every page built from a template. The fix was one line, and it stayed fixed.
Chasing the same repeatability, I pushed too much into scripts. One was a regex parser meant to pull the real service call a button invokes out of arbitrary Java method bodies. A model reads method-body control flow better than a regex does, and this was the highest-stakes field in the spec: get it wrong and the migrated view calls the wrong service. I gave it back to the model.
What stayed scripted was fact retrieval: i18n keys out of properties files, validation annotations off an entity, a view's canonical name from its path. My rule now: script a step only if it's deterministic and tedious, a script can do it reliably, and the model wouldn't do it better.
Seven agents
The second half is a loop over the tracker, and whatever picks up a ticket reads that ticket and nothing else. It fetches the ticket, checks that its dependencies are closed, does what the body says, commits, closes it and asks what's unblocked next. Early versions reached back into local spec files to reconstruct what to do, which tied every resolution to state that could be stale.
The dependency graph puts services first and screens second, and the screens are where parallelism paid off: nineteen views that share no code, each needing a view class, a presenter and tests. I ran seven agents at once, grouped by module, then one more to run the full suite. There is nothing to arbitrate: each agent writes its own set of files, and each ticket carries its own spec.

Before: the wallet form in the JSF 2 and PrimeFaces application.

After: the same form in Vaadin 25 Flow, with component defaults and no design system applied.
The two forms have the same fields and the same required markers, built from different components. Nobody wrote that field list by hand: it came out of the behavior spec, and each component was chosen from the entity's Java type rather than the old page's markup. actualBalance is a BigDecimal, so it became a BigDecimalField. Early implementations picked from the old UI tag, so an expiration date came out as a plain text box because the original used a text input. Choosing by Java type makes the component itself a layer of validation: a DatePicker can't hold "abc".
Where it broke
The failures showed up at three points: compile time, startup, and in the browser.
Compile-time failures were the boring kind: namespace swaps, annotation changes and base-interface conversions need no judgment, and the compiler grades them. The agents were most useful on moving DeltaSpike's Criteria API to Spring Data Specifications, where join topology doesn't map by pattern.
Startup caught the queries. Several had been carried over as SQL-style JOIN … ON against table names, and JPQL needs entity names instead. Unit tests don't validate query strings; Hibernate validates them at boot. The other startup failure came from a scanBasePackages setting: component scanning honors it, Spring Data's repository auto-configuration doesn't. The log said Found 0 JPA repository interfaces.
The rest only showed up in a browser, and the worst was thirteen form views that rendered a blank content area with nothing in the log. Finding it meant intercepting the UIDL (the JSON diff the server sends the browser) and comparing a view that rendered against one that didn't. The broken one sent its node with an empty children array.
This one was my bug. Generated views populated their UI inside beforeEnter(). Moving construction to afterNavigation() fixed all thirteen.
Both hooks compile and survive code review, yet in these generated views one of them produced an empty page, and nothing in the language marks the difference.
Two more surfaced in the same pass. Enum dropdowns showed raw message-bundle keys, card-type.debit instead of "Debit", because nothing resolved them through a MessageSource. The user edit form pre-filled the password field with the stored bcrypt hash, which puts the hash in the browser and double-hashes it on save.
The last took longest to describe, because the symptom was "it logs me out every couple of clicks, at random." Spring Security 7's default SecurityContextRepository stores the context as a servlet request attribute as well as in the session. In this app, one line pinning it to session storage stopped the logouts:
http.securityContext(ctx -> ctx
.securityContextRepository(new HttpSessionSecurityContextRepository()))
.with(VaadinSecurityConfigurer.vaadin(), c -> c.loginView(LoginView.class)); All of these sit between two layers: the navigation engine and the serializer, the security filter chain and the Vaadin filter. A unit test only exercises one side. A view ticket now doesn't close until someone has driven its whole flow in a browser.
What is left, and how to try it
The toolchain is finished. On web-budget, the scaffold is committed, the service layer is ported, and the screens are being built out from their tickets.
I deliberately didn't build a headless runner. Every step invokes tools that would otherwise ask for approval, so an unattended script either hangs or gets blanket permissions. For a process whose job is rewriting somebody else's code, I'd rather have a person approve those prompts than hand out blanket permissions.
Nothing in it knows anything about web-budget. The stack detector works out the base package, the service packages to keep and the UI packages to discard. Point it at another application and you get another backlog.
The skills and scripts are here:
github.com/sujoykd/jsf_to_vaadin_migration
You'll need JDK 21 and Maven, a headless Chromium for the screenshot pass, and an issue tracker. I used Forgejo, for its native issue dependencies and because I could self-host it. Everything else lives in one git-ignored config file.
Colleagues ran the same experiment on other stacks: Oracle Forms to Java, where counting migrated elements turned out not to mean they rendered, and SWT and Eclipse RCP on the web, on what could and couldn't port at all.
This has only been run against one application. If you have a JSF 2 system nobody wants to touch, the first two steps in the table, jsf-migration-assess and jsf-screenshot-tour, read it without changing anything, and what they report is most of what you'd want to know before committing to a migration.