Skip to content

Repository files navigation

Actuo

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.


What is WebMCP-specific here

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 toolsapprove_expense registers only while you can actually approve something, and fires toolchange as that changes frontend/src/app/webmcp/tool-session.ts
Cancellationgenerate_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 annotationsreadOnlyHint 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

The tools

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.

One design decision worth knowing

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.


Trying it

1. Run it

pnpm install          # pnpm, not npm — see CONTRIBUTING notes in CLAUDE.md
pnpm run dev          # backend :3000, frontend :4200

Open 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.

2. Give the Copilot a key

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.

3. Turn on WebMCP (optional)

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.

What to look at

  • State-gated tool. Sign in as priya. approve_expense is in document.modelContext.getTools(). Sign in as arjun instead 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. /agent embeds 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 calls convertCurrency across the origin boundary, the embedded widget moves to that conversion, and the tool card carries a via cambiaro.programmersingh.dev badge. 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 arjun and the approve controls are gone from the Expenses page — and POST /api/expenses/:id/approve still returns 403 if you call it directly. Sign in as priya and try to approve an expense she filed herself: no button, and the API refuses that too.

Layout

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.


Deploying

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 something

That last one matters more than it looks — see Allowed hosts below.

Why a Dockerfile and not Firebase App Hosting

App Hosting failed to build this repository three times, each for a different reason inside its Node buildpack, none reproducible locally:

  1. It reads engines.pnpm as a range and installs the highest match while ignoring packageManager, 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.
  2. Capped at pnpm 9, pnpm install died with Cannot convert undefined or null to object in ~314ms, before any network fetch — firebase-tools#10435, filed for pnpm monorepos and closed as not planned.
  3. Migrated to npm, the install succeeded and the build failed: npm run build --workspace=@actuo/shared reported No workspaces found in 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

render.yaml is a Blueprint: Render builds the committed Dockerfile and runs it. There is no framework detection anywhere in the path.

  1. Render Dashboard → NewBlueprint → point it at this repository.
  2. It reads render.yaml and prompts for the three sync: false secrets — SUPABASE_SERVICE_ROLE_KEY, JWT_ACCESS_SECRET, JWT_REFRESH_SECRET. They are stored by Render and never committed.
  3. 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 redirect

It 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.

Anywhere else

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 actuo

Set PUBLIC_ORIGIN too

The 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.xml

Allowed hosts — the one that fails silently

Two 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-context

The cross-origin demo on a deployed URL

CONVERTER_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:

  1. CONVERTER_URL is set on the service (it is in render.yaml), and
  2. the converter registers its tools with exposedTo naming 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.


Status

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.

About

Actuo is an expense management platform where every action is also a WebMCP tool, so an AI agent can operate it with full transparency.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages