Skip to content

feat(webmcp): let agents drive the app instead of posting behind it - #12

Merged
theprogrammersingh merged 1 commit into
mainfrom
feat/agent-driven-ui
Sep 4, 2026
Merged

feat(webmcp): let agents drive the app instead of posting behind it#12
theprogrammersingh merged 1 commit into
mainfrom
feat/agent-driven-ui

Conversation

@theprogrammersingh

Copy link
Copy Markdown
Owner

Why

Every WebMCP tool posted straight to /api/*, so nothing on screen moved. An
agent approving an expense left the user looking at a row that still read
"Submitted". ToolRegistry.observe() refreshes only Session.pendingApprovals,
whose sole consumer is ToolSession gating a tool — no page reads it — and
every page's resource() params is signal-free, so no signal change could
re-trigger a load either.

A human cannot add an expense without going to /add, or change a budget
without going to /budgets. Now neither can an agent.

What

Writes go through the page. submit_expense, approve_expense and the new
set_budget live in tools/page-driven-tools.ts, which injects no
ApiClient
. Each navigates to the owning page, waits for it to mount, and
hands over the arguments. The page performs the work through the same method its
own buttons call — so the optimistic row patching, the form messages, the
validation and the reloads all apply with no new plumbing.

tool page how it becomes visible
submit_expense /add fields fill one at a time, then it saves and submits for approval
approve_expense /expenses row scrolled into view, badge changes in place via run()
set_budget /budgets form fills, commits through the Save button's own path, bars reload

set_budget is new. POST/PATCH /budgets and the create/edit form both
already existed; only the tool was missing, so an agent could read a budget
with get_budget_status and never change one.

navigate_to moves the browser between the seven authenticated pages. Its
enum descriptions double as the map of the app an agent reads straight off
getTools(), instead of reading the DOM and guessing where to click.

The Cambiaro converter is now a card on the dashboard, open on arrival. A
cross-origin tool lives only as long as the document that registered it, so a
collapsed frame on the landing screen meant the Copilot had no convertCurrency
until someone clicked. One mount only — two frames would publish the tool twice.

Details worth a reviewer's attention

  • PageActions has no API fallback on timeout, deliberately. A fallback
    would restore exactly the invisible path this removes, and only when something
    went wrong — a slow chunk, a guard redirect. A timeout is an error the model
    reports.
  • Unregister clears only the handler it installed. Angular builds the
    incoming component before destroying the outgoing one, so an unconditional
    delete lets a page being torn down wipe the incoming page's registration.
  • The /add handler does not use form.requestSubmit(). onSubmit stamps
    the audit row actor: agentInvoked ? 'agent' : 'human', and a synthetic
    submit carries no agentInvoked — it would file the agent's work as a
    person's. That flag is the only thing in the app that can produce
    actor: 'human', and the audit viewer's contrast is built on it.
  • The fill is paced (core/agent/fill-pacing.ts). Filling and submitting in
    one frame leaves no frame in which the filled form is on screen — identical to
    the invisible POST it replaced. Specs drive the stagger at 0.
  • The Copilot collapses to the orb below sm, where the panel is
    fixed inset-0 and would hide the page it is driving. Collapses for the turn,
    not per call.
  • submit_expense still means create and submit for approval; the
    declarative add_expense_form creates a draft only, so the handler performs
    the transition rather than quietly narrowing the contract.
  • The declarative form's annotations are untouched.

Verification

1,051 tests pass — 12 shared, 149 backend, 890 frontend (34 new). Clean
production build and prerender. App hydrates with no console errors after
pnpm run dev:clean (required: SET_BUDGET is a new @actuo/shared export).

Not yet verified: the signed-in walkthrough — logging an expense, approving
it, and setting a budget through the Copilot, plus the mobile collapse at
< 640px.

Every tool posted straight to /api/*, so nothing on screen moved: an agent
approving an expense left the user looking at a row that still read
"Submitted". ToolRegistry.observe() refreshes only Session.pendingApprovals,
whose sole consumer is ToolSession gating a tool, and every page's resource()
params is signal-free, so nothing could re-trigger a load.

A human cannot add an expense without going to /add, or change a budget
without going to /budgets. Now neither can an agent.

- page-actions.ts is the rendezvous: a page publishes an action while mounted,
  a tool waits for it. No API fallback on timeout, deliberately — a fallback
  would restore the invisible path this removes, and only when something went
  wrong. Unregister clears only the handler it installed, since Angular builds
  the incoming component before destroying the outgoing one.
- page-driven-tools.ts holds every write and injects no ApiClient. Each one
  navigates to the owning page, waits, and hands over the arguments; the page
  does the work through the same method its own buttons call, so the row
  patching, form messages and reloads all apply unchanged.
- set_budget is new. POST/PATCH /budgets and the form already existed; only
  the tool was missing, so an agent could read a budget and never change one.
- navigate_to moves the browser between the seven authenticated pages, with
  enum descriptions that double as the map of the app an agent reads off
  getTools(). Pinned against the real router config in both directions.
- The Cambiaro converter is now a card on the dashboard, open on arrival: a
  cross-origin tool lives only as long as the document that registered it, so
  a collapsed frame meant the Copilot could not convert until someone clicked.

The /add handler does not use form.requestSubmit(): onSubmit stamps the audit
row actor: agentInvoked ? 'agent' : 'human', and a synthetic submit carries no
agentInvoked, so it would file the agent's work as a person's. That flag is the
only thing that can produce actor: 'human'.

The fill is paced and the Copilot collapses to the orb below sm, where the
panel is fixed inset-0 and would hide the page it is driving.
@theprogrammersingh
theprogrammersingh merged commit f55ac91 into main Sep 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant