AI-native expense intelligence, and a reference implementation of the WebMCP standard.
Actuo is a multi-tenant expense management platform (Angular SSR + NestJS + Supabase) where every meaningful user action is also a WebMCP tool. An AI agent can drive the app through the same routes, the same auth and the same RBAC as a person — and every call it makes is written to an audit log you can read.
The differentiated piece is the Actuo Copilot: a site-agnostic chat widget
that discovers tools through document.modelContext, reasons with the user's own
Gemini key entirely client-side, and shows every tool call as an expandable card.
This is the part worth reviewing. PRD §7 is a coverage checklist and each row has a concrete implementation:
| Aspect | Where |
|---|---|
Declarative API — a tool derived from annotated HTML, with no registerTool() call anywhere |
frontend/src/app/pages/add-expense/add-expense.ts |
Imperative registerTool — five tools, each with its own registration lifetime |
frontend/src/app/webmcp/tool-registry.ts, frontend/src/app/tools/expense-tools.ts |
| JSON Schema inputs — one definition used by the client and validated by the server | shared/src/tools.ts |
Dynamic / state-gated tools — approve_expense registers only while you can actually approve something, and fires toolchange as that changes |
frontend/src/app/webmcp/tool-session.ts |
Cancellation — generate_report honours AbortSignal; the server abandons the job mid-flight, not just the client |
frontend/src/app/tools/expense-tools.ts, backend/src/reports/ |
| Cross-origin tools — the Copilot discovers and calls tools published by a separately built, independently deployed app Actuo does not own | frontend/src/app/converter/, frontend/src/app/pages/agent/agent.ts |
Security annotations — readOnlyHint on reads; untrustedContentHint where a result carries text a person wrote; mutating tools require in-chat confirmation before they run |
shared/src/tools.ts, frontend/src/app/copilot/copilot.ts |
| Tool | ||
|---|---|---|
search_expenses |
read-only | Search by text, status or date range |
get_budget_status |
read-only | Per-category budget, spend, remaining, utilization |
generate_report |
read-only | CSV for a date range — long-running, cancellable |
submit_expense |
mutating | Create an expense and submit it for approval |
approve_expense |
mutating, state-gated | Approve or reject — registered only when the queue is non-empty and you are an admin or owner |
Plus add_expense_form, which the browser derives from the annotated markup of
the Add Expense page.
getTools() returns descriptors with no execute handle, and executeTool()
is a Chromium extension rather than part of the core spec. So the Copilot cannot
be built the obvious way. Instead a single ToolRegistry is the source of truth:
it publishes each tool to WebMCP so external browser agents see real tools, and
keeps the execute functions locally so the in-page Copilot calls them directly
— which works in every browser with no flag at all. executeTool() is used for
exactly one thing: cross-origin tools, the one path that genuinely needs it.
That is why the app is fully usable without the Chrome flag, and flag dependence is confined to the cross-origin demo.
pnpm install # pnpm, not npm — see CONTRIBUTING notes in CLAUDE.md
pnpm run dev # backend :3000, frontend :4200Open http://localhost:4200 and sign in with a seeded account:
priya@actuo.demo |
Demo1234! |
owner — can approve |
arjun@actuo.demo |
Demo1234! |
member — cannot |
The backend needs backend/.env (copy backend/.env.example) pointing at a
Supabase project with supabase/migrations/ applied.
Settings → AI & Copilot, paste a Google AI Studio
key, press Test key. The key is stored only in your browser and goes
straight to Google — it is never sent to, proxied by, or logged by Actuo's
servers. You can watch that in DevTools → Network: the key appears only in
requests to generativelanguage.googleapis.com.
Everything above works without this. To see Actuo's tools as real WebMCP tools that an external agent can drive, enable in Chrome 149+:
chrome://flags/#enable-webmcp-testing ("Enables the WebMCP API")
chrome://flags/#devtools-webmcp-support
or launch Chrome with --enable-blink-features=WebMCP. Then visit /agent,
which shows what this page publishes, what it can reach on other origins, and a
live log of every tool call.
- State-gated tool. Sign in as
priya.approve_expenseis indocument.modelContext.getTools(). Sign in asarjuninstead and it is gone, even though the approval queue is not empty — and the API returns 403 either way, because the gate is UX and the guard is the boundary. - Cross-origin.
/agentembeds Cambiaro, a currency converter built and deployed separately, which knows nothing about Actuo beyond the origin it is handed. Ask the Copilot what €80 is in rupees; it callsconvertCurrencyacross the origin boundary, the embedded widget moves to that conversion, and the tool card carries avia cambiaro.programmersingh.devbadge. The answer is quoted as that site's, with its rate and date — it never becomes an Actuo figure. - Cancellation. Ask for a report, then press Stop. The UI reacts immediately and the server abandons the job.
- Audit trail. Settings has two panels and they are not the same thing: Tool calls is every WebMCP invocation (filter it to Agent, then Human); Change history is every state change, including ones made by clicking. Approve an expense from the Expenses page and it appears in the second, not the first.
- Humans and agents share one permission model. Sign in as
arjunand the approve controls are gone from the Expenses page — andPOST /api/expenses/:id/approvestill returns 403 if you call it directly. Sign in aspriyaand try to approve an expense she filed herself: no button, and the API refuses that too.
frontend/ Angular 21 — SSR, zoneless, standalone components, Signals, Tailwind v4
backend/ NestJS 12 (ESM) — auth, RBAC, business logic, all Supabase access
shared/ @actuo/shared — DTOs, domain types, WebMCP tool JSON Schemas
docs/ PRD, design doc, init guide
Two codebases, one deployable: server.mjs at the repository root runs a
single Node process in which Nest owns /api/* and the Angular SSR handler takes
everything else. It installs as a PWA — manifest, service worker, offline
banner — and the worker deliberately caches no /api response, because
stale money is worse than a spinner.
Boundaries that are not negotiable (see CLAUDE.md for the full list): the
frontend never talks to Supabase, the Gemini key never reaches our servers, and
roles are enforced server-side on every request.
pnpm test # shared + backend unit + frontend
pnpm run test:e2e # backend e2e — a separate config, not included above
pnpm run build # shared -> backend -> frontend, then the SEO origin stamp.github/workflows/ci.yml runs exactly that gate on every push. It needs no
secrets: EnvService raises on a missing variable at call time rather than
import time, so the app boots and runs its e2e suite without credentials.
The target is Render, running the committed Dockerfile. server.mjs is a
plain Node server that reads $PORT and binds 0.0.0.0, so any host that takes
a container will do.
Check it locally first — this is the whole deploy in one command:
pnpm run build && node server.mjs # :8080
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' localhost:8080/ # 200 text/html
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' localhost:8080/api/health # 200 application/json
curl -s localhost:8080/api/nope # JSON 404, not the app shell
curl -s localhost:8080/ | grep -o 'ng-server-context="[^"]*"' # must print somethingThat last one matters more than it looks — see Allowed hosts below.
App Hosting failed to build this repository three times, each for a different reason inside its Node buildpack, none reproducible locally:
- It reads
engines.pnpmas a range and installs the highest match while ignoringpackageManager, so">=10"selected pnpm 12 — whose branch in the buildpack launches the standalone binary from a path that only exists in the npm package layout.MODULE_NOT_FOUND. - Capped at pnpm 9,
pnpm installdied withCannot convert undefined or null to objectin ~314ms, before any network fetch — firebase-tools#10435, filed for pnpm monorepos and closed as not planned. - Migrated to npm, the install succeeded and the build failed:
npm run build --workspace=@actuo/sharedreportedNo workspaces foundin the builder's tree, though it resolves everywhere else.
All three are the builder disagreeing with a workspace monorepo. The Dockerfile ends the category — the image runs the same commands that run locally. The npm migration was reverted; this repo is on pnpm.
render.yaml is a Blueprint: Render builds the committed Dockerfile and runs
it. There is no framework detection anywhere in the path.
- Render Dashboard → New → Blueprint → point it at this repository.
- It reads
render.yamland prompts for the threesync: falsesecrets —SUPABASE_SERVICE_ROLE_KEY,JWT_ACCESS_SECRET,JWT_REFRESH_SECRET. They are stored by Render and never committed. - Apply. Subsequent pushes to the tracked branch redeploy automatically.
Everything else — NG_ALLOWED_HOSTS, PUBLIC_ORIGIN, SUPABASE_URL,
BASE_CURRENCY — is declared in render.yaml. Change SUPABASE_URL to your own
project before the first deploy.
Not the free plan. The Angular SSR build and the Nest build both run inside the image build, and free build memory is not enough. Free instances also spin down when idle, and the cold start here is an image pull plus a Nest boot.
PUBLIC_ORIGIN is the canonical origin, and changing it needs a redeploy.
Its build-time half works on Render without a --build-arg flag: Render
translates a service environment variable into a Docker build argument, and the
Dockerfile declares ARG PUBLIC_ORIGIN to receive it. A runtime variable cannot
reach already-prerendered HTML, so a restart is not enough.
server.mjs reads it again at runtime and 308-redirects page requests
arriving on any other hostname to it, so the two origins the service answers
on do not compete as duplicate content. /api is never redirected, and neither
is localhost. It also switches itself off if PUBLIC_ORIGIN names a host
missing from NG_ALLOWED_HOSTS — that pairing would redirect the alias to a
host Angular answers 400 for, taking every page down while /api/health still
reported healthy.
The site answers on two hostnames, and both must be in NG_ALLOWED_HOSTS
(a comma-separated list) or their pages return 400 Bad Request naming the
host:
| Hostname | Role |
|---|---|
actuo.programmersingh.dev |
canonical — PUBLIC_ORIGIN, and what every canonical, og:image and sitemap <loc> names |
actuo.onrender.com |
alias — 308s to the canonical one |
After any deploy, run the smoke check against the canonical origin:
pnpm run verify:deploy https://actuo.programmersingh.dev
pnpm run verify:deploy https://actuo.onrender.com # expects the redirectIt checks the things that are only true when the deploy is correct — the API
answers, / actually server-renders, no __PUBLIC_ORIGIN__ sentinel survives,
the stamped origin matches the URL being checked, and the converter is
configured on another origin — and names the fix for each failure. Every check
is there because that thing broke in production without anything else
noticing.
The image is the portable unit. server.mjs reads $PORT and binds 0.0.0.0,
so Cloud Run, Fly and Railway take it unchanged:
docker build --build-arg PUBLIC_ORIGIN=https://your-host -t actuo .
docker run -p 8080:8080 -e NG_ALLOWED_HOSTS=localhost actuoThe public pages are prerendered, so absolute URLs — the sitemap's <loc>,
og:image, canonical — have to be decided at build time. index.html,
sitemap.xml and robots.txt carry a __PUBLIC_ORIGIN__ sentinel that
scripts/stamp-seo.mjs replaces as the last step of pnpm run build. Unset,
everything stays root-relative and valid; set, it becomes absolute.
PUBLIC_ORIGIN=https://your-host pnpm run build
grep -o '<loc>[^<]*</loc>' frontend/dist/frontend/browser/sitemap.xmlTwo different things switch SSR off behind a deploy, and they look nothing alike.
NG_ALLOWED_HOSTS is the host allowlist (SSRF protection). With it configured, a
hostname that is not on the list gets a 400, not a silent downgrade — and the
value is unioned with the build-time list in angular.json, not a replacement.
The one that fails silently is proxy headers. Angular downgrades to
client-side rendering, with a perfectly normal 200, whenever it receives an
x-forwarded-* header it was not told to trust — its default covers only
x-forwarded-host and x-forwarded-proto, while most platform proxies also send
x-forwarded-for. frontend/src/server.ts trusts the full set, overridable with
NG_TRUST_PROXY_HEADERS.
After any deploy, confirm the HTML for / contains ng-server-context — which
is what pnpm run verify:deploy does, and it names which of the two causes it
is. You can reproduce the proxy-header case locally without deploying:
curl -s -H 'X-Forwarded-For: 203.0.113.9' localhost:8080/ | grep ng-server-contextCONVERTER_URL is the base URL the app frames, and it has to be an origin the
app does not serve — a same-origin page's tools come back marked same-origin,
the Copilot filters them out, and the surfaces say exactly that rather than
showing an empty list.
It points at an independently deployed currency converter, which is a real second origin and also a real feature. Development uses the same one rather than a local stand-in, so there is no configuration that is only exercised in production. Two things must both be true for the Copilot to reach its tools:
CONVERTER_URLis set on the service (it is inrender.yaml), and- the converter registers its tools with
exposedTonaming this app's origin.
The second is the one that is easy to miss. A WebMCP tool is visible only to its
own document unless registration opts in, so the framed page is told which
origin to expose to via the ?actuo= parameter the app appends. Until it
honours that, the frame still works by hand and the tool list is honestly empty.
CONVERTER_URL defaults to the public converter in development, so the
cross-origin path works with no setup — at the cost of needing a network. It is
deliberately not defaulted in production: a deploy should name the converter
it trusts rather than inherit one, and unset the surfaces say so.
Progress.md tracks every PRD feature against what is actually implemented,
including a DEAD status for code that exists and passes tests but has no caller.
It is deliberately evidence-based rather than aspirational; start there.
Built for a hackathon deadline. CLAUDE.md carries the architectural rules and
the non-obvious constraints worth knowing before changing anything.