Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 82 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,66 @@ It is set in the runtime stage of the Dockerfile, and deliberately NOT at build
time — a production-flagged install drops devDependencies, and the build is
almost entirely devDependencies.

## Writes go through the page, never behind it

**What a human can do, the agent can do; and it does it where a human would.**
A person cannot add an expense without going to `/add`, or change a budget
without going to `/budgets`. Neither can an agent.

Every mutating tool — `submit_expense`, `approve_expense`, `set_budget` — lives
in `frontend/src/app/tools/page-driven-tools.ts`, which **injects no
`ApiClient`**. Its `execute()` does three things: navigate to the page that owns
the action, wait for that page to mount, and hand it the arguments. The page
performs the work through the same method its own buttons call.

That last part is what makes it worth the machinery. The tool gets the row
patching, the form messages, the validation and the reloads that already exist,
because it is running the same code. `ExpensesPage.run()` merges the decision
into `patched` exactly as the Approve button does, so the visible row changes
and no `Load more` page is thrown away; `BudgetsPage.commit()` is what the Save
button calls, so the bars reload and a 403 is worded the way the form already
words it.

These tools used to POST directly, and the effect was that **nothing on screen
moved**. `ToolRegistry.observe()` refreshes only `Session.pendingApprovals`, and
the sole consumer of that signal is `ToolSession` gating a tool — no page reads
it. Every page's `resource()` `params` is signal-free, so no signal change can
re-trigger a load either. An agent approving an expense left the user looking at
a row that still said "Submitted".

Four things here are load-bearing:

- **`PageActions` has no API fallback when no page answers.** A fallback would
restore the invisible path this exists to remove, and would do it only when
something went wrong — a slow chunk, a guard redirect. A timeout is an error
the model reports.
- **Unregistering only clears the handler it installed.** During a route change
Angular builds the incoming component before destroying the outgoing one, so
an unconditional delete lets a page being torn down wipe the registration the
new page just made. Same ordering `ConverterSession` documents.
- **The `/add` handler does not use `form.requestSubmit()`.** The form's own
`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 in the app that can produce
`actor: 'human'`, and the audit viewer's contrast is built on it.
`ToolRegistry.log()` already records the call.
- **The fill is paced, and the pause is not decoration.** `core/agent/fill-pacing.ts`
staggers fields and waits before saving. Filling and submitting in one frame
leaves no frame in which the filled form is on screen — indistinguishable from
the invisible POST it replaced. Specs drive the stagger at 0.

The Copilot collapses to the orb below the `sm` breakpoint before a page-driven
tool runs (`Copilot.collapseForPageAction()`), because the panel is
`fixed inset-0` there — a full-screen sheet the driven page would be hidden
behind. It collapses for the *turn*, not per call, so a turn that files an
expense and then approves it does not flicker. From `sm:` up the panel is
already non-blocking and nothing happens.

`submit_expense` still means create **and** submit for approval, as it always
has. The declarative `add_expense_form` creates a draft only, so the `/add`
handler performs the transition after the save rather than quietly narrowing the
contract.

## The expense workflow is one table, shared

Which action is legal, who may perform it, and on whose row — all of it lives in
Expand Down Expand Up @@ -523,7 +583,11 @@ request; the access token deliberately carries no role claim.
`App` subscribes once and fans out to the audit write and the
pending-approval re-poll. Keep HTTP and session dependencies out of the
registry itself — that is what keeps its spec free of fakes.
- `tools/` — the five tool `execute()` implementations over `/api/*`.
- `tools/` — the tool `execute()` implementations, split by what they touch so
each spec needs only its own fakes. `expense-tools.ts` is the six reads over
`/api/*`; `navigation-tools.ts` is `navigate_to`, which touches the `Router`
and nothing else; `page-driven-tools.ts` is every write, and touches **no
`ApiClient` at all** — see "Writes go through the page" below.
- `copilot/` — `Copilot` (the agent loop) and `CopilotPanel` (orb + panel).
- `core/api/` — `ApiClient`. `core/theme/` — `ThemeService`.
- `pages/add-expense/` — the declarative WebMCP form. It is the only tool call a
Expand Down Expand Up @@ -634,8 +698,12 @@ rows were, at hand-written rates nobody published. It lives under `backend/`
because pnpm hoists nothing: a root script cannot resolve `@nestjs/core`.

**The embedded converter does not change this, and must not.** `converter/`
frames a separate converter app on four surfaces (`/convert`, `/agent`, the
dashboard's excluded-rows notice, and expense rows in another currency). It is
frames a separate converter app on four surfaces (`/convert`, `/agent`, a card
on the dashboard, and expense rows in another currency). The dashboard's and
`/convert`'s open on arrival; the other two are triggers. Opening on the
dashboard is deliberate — a cross-origin tool lives only as long as the document
that registered it, so a collapsed frame on the screen people land on means the
Copilot cannot convert until someone clicks. It is
advisory: a rate a person reads off another site *today* is not the historical
rate locked at entry, so nothing it shows may reach `converted_amount`,
`sumSpend()`, `sumByCategory()`, or the `excludedNotice()` copy. The FX pass
Expand Down Expand Up @@ -669,7 +737,17 @@ These are the load-bearing constraints — most bugs worth preventing here are v
PRD §7 is a checklist every row of which needs a concrete implementation. When adding a tool, know which aspect it demonstrates:

- **Declarative** — the Add Expense quick-entry form is annotated HTML with *no* JS tool registration. Keep it plain.
- **Imperative** (`registerTool`) — `submit_expense`, `search_expenses`, `get_budget_status`, `approve_expense`, `generate_report`
- **Imperative** (`registerTool`) — `submit_expense`, `search_expenses`, `get_budget_status`, `approve_expense`, `set_budget`, `generate_report`
- **Navigation** — `navigate_to` moves the browser between the app's authenticated
pages. It exists for agents driving Actuo from outside, which otherwise have to
read the DOM and guess where to click, and its enum descriptions double as the
map of the app an agent reads straight off `getTools()`. The destination table
is `APP_DESTINATIONS` in `shared/src/tools.ts`, pinned against the real router
config in both directions by `tools/navigate-destinations-contract.spec.ts` —
every `authGuard` route is a destination and every destination is one, so a new
gated page cannot ship undescribed. It is **not** `readOnlyHint` (it moves the
page), which is why `app.ts` exempts it by name from the pending-approval
re-poll that every other mutating tool triggers.
- **State-gated** — `approve_expense` registers only when the user is `admin`/`owner` AND a pending item exists; emits `toolchange`
- **Cancellation** — `generate_report` honors `AbortSignal`; the UI must react within ~100ms
- **Cross-origin** — the Copilot must work embedded on an unrelated demo page (iframe + `exposedTo`/`fromOrigins`/`allow="tools"`) with no code changes
Expand Down
21 changes: 19 additions & 2 deletions Progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ their own expense.
| Original + converted amounts stored | 1 | ✅ | Columns exist. `core/expense/amount.ts` owns the rule: `sumSpend()` adds only base-currency rows and reports the rest |
| Live FX + daily cache | 1 | ✅ | `backend/src/fx/` reads ECB rates from `api.frankfurter.dev` and caches them in `fx_rates` (migration `0003`). Keyed on the date *asked for*, not the date the rate is from, or every weekend lookup would miss forever; the only entry that can go stale is today's while it still stands in for an earlier day. `FxService.rateOn` returns `null` rather than throwing, so an unreachable publisher cannot stop an expense being filed |
| Historical rate lock | 1 | ✅ | `expenses.fx_rate` + `fx_rate_date`, written with `converted_amount` at the expense's own date. **`fx_rate_date` is not `expense_date`** — the ECB publishes once per working day, so a Saturday expense locks Friday's rate, and the row prints the rate's own date. An edit re-locks on amount, currency *or* date. Verified live: AWS filed 2026-08-29 (a Saturday) carries the 2026-08-28 rate |
| Embedded converter (advisory) | 1 | ✅ | `converter/currency-converter.ts` frames a separate converter app on `/convert`, `/agent`, the dashboard notice and foreign-currency expense rows. One frame at a time, lazily mounted, `CONVERTER_URL` from `GET /api/config` |
| Embedded converter (advisory) | 1 | ✅ | `converter/currency-converter.ts` frames a separate converter app on `/convert`, `/agent`, a dashboard card and foreign-currency expense rows. `/convert` and the dashboard open on arrival — the dashboard because a cross-origin tool lives only as long as the document that registered it, so a collapsed frame on the landing screen means the Copilot cannot convert until someone clicks. One frame at a time, `CONVERTER_URL` from `GET /api/config` |

> **The exclusion rules were what made FX cheap to land.** `sumSpend()` returns
> `{total, excluded}` and `sumByCategory()` returns `unconverted` — a row counts
Expand Down Expand Up @@ -235,7 +235,10 @@ Cross-origin is live as of 2026-08-29; only the standalone-script packaging (Pha
| Aspect | Status | Notes |
|---|---|---|
| Declarative API (annotated form) | ✅ | Add Expense: `toolname`/`tooldescription`/`toolparamdescription`/`toolautosubmit`, `agentInvoked` + `respondWith`, **no JS registration** |
| Imperative `registerTool` | ✅ | Six tools, per-tool `AbortController` lifetime |
| Imperative `registerTool` | ✅ | Eight tools, per-tool `AbortController` lifetime |
| **Writes drive the visible page** | ✅ | `submit_expense`, `approve_expense` and `set_budget` navigate to the page that owns the action and hand the work to it, so the user watches it happen — the form fills field by field, the row's badge changes in place, the budget bar moves. `page-driven-tools.ts` injects no `ApiClient`, so there is no path back to posting behind the page. Rendezvous in `webmcp/page-actions.ts` |
| **Budget editing** | ✅ | `set_budget` creates or updates through the Budgets form. The route (`POST`/`PATCH /budgets`) and the form both already existed; only the tool was missing, so an agent could read a budget and never change one |
| **Agent navigation** | ✅ | `navigate_to` moves the browser between the seven authenticated pages, so an agent driving Actuo from outside does not have to read the DOM and guess where to click. Its enum descriptions are the map of the app, read straight off `getTools()`. `APP_DESTINATIONS` is pinned against the real router config in both directions by `tools/navigate-destinations-contract.spec.ts` |
| JSON Schema inputs | ✅ | One definition in `shared/src/tools.ts`, used by client and server |
| **Dynamic / state-gated tools** | ✅ | The shell polls on sign-in and after every mutating call. Verified live: `approve_expense` present in `getTools()` as owner with 3 pending, absent as member, and every tool retired on sign-out |
| Cancellation (`AbortSignal`) | ✅ | Client aborts, polls stop, server abandons the job mid-fetch and mid-format |
Expand All @@ -247,6 +250,20 @@ Cross-origin is live as of 2026-08-29; only the standalone-script packaging (Pha
> Open question: `generate_report` is annotated `readOnlyHint: true` but creates a
> server-side job. Defensible, but decide it deliberately.

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

`navigate_to` is deliberately **not** `readOnlyHint`: it reads and writes no
data, but it changes what the user is looking at, and that is what the flag
tells a client. It is the same category the embedded converter's own UI-moving
tools sit in, which `/agent` already renders as `Mutating`. The one consequence
is that `app.ts` exempts it by name from the pending-approval re-poll every
other mutating tool triggers — navigation cannot change the queue, and an agent
walking the app would otherwise fire a search per hop.

`download_report` is the companion tool that saves a finished report to disk. It
exists because a file has no other route out for a client that can only call
tools: the download route needs the session bearer header, so no URL — in chat or
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/ai/gemini-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,8 @@ describe('toGeminiSchema', () => {
'generate_report',
'download_report',
'fetch_categories',
'navigate_to',
'set_budget',
'approve_expense',
]);
for (const declaration of declarations) {
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
inject,
} from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { NAVIGATE_TO } from '@actuo/shared';
import { CopilotPanel } from './copilot/copilot-panel.js';
import { Session } from './core/session/session.js';
import { ThemeService } from './core/theme/theme-service.js';
Expand Down Expand Up @@ -243,7 +244,15 @@ export class App {
// An approval decision changes the queue, which closes the gate. Reads
// cannot, and `search_expenses` runs often enough that polling on it
// would be a request per question.
if (this.registry.isMutating(invocation.toolName)) {
//
// `navigate_to` is mutating by the `readOnlyHint` test — it moves the
// page — but it cannot touch the queue, and an agent walking the app
// would otherwise fire a search per hop. The network tab is part of what
// this app demonstrates, so the exemption is worth the named check.
if (
this.registry.isMutating(invocation.toolName) &&
invocation.toolName !== NAVIGATE_TO.name
) {
void this.session.refreshPendingApprovals();
}
});
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/app/copilot/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ const SYSTEM_INSTRUCTION = [
'Amounts are money: state the currency, and never invent figures you did not read from a tool.',
"Tools from another origin are advisory: quote a result as that site's answer, say what rate",
'and date it used, and never fold one into an Actuo total or present it as an Actuo figure.',
'navigate_to only moves the screen: it returns no expense data, so never call it to answer a',
'question. Use it when the user asks to see a page, or when what they asked for is something',
'they need to be looking at.',
'Never write download links or file URLs: a link you invent points at a route the browser',
'cannot authenticate. When the user asks to download, save or export a report, call',
'download_report with the jobId generate_report returned.',
Expand Down Expand Up @@ -71,6 +74,8 @@ export class Copilot {
private controller: AbortController | null = null;
/** Resolves when the user answers a confirmation card. */
private pendingConfirmation: ((approved: boolean) => void) | null = null;
/** True while the panel is hidden so a page-driven tool stays watchable. */
private collapsed = false;

readonly entries = this.entryList.asReadonly();
readonly isBusy = this.busy.asReadonly();
Expand Down Expand Up @@ -148,9 +153,41 @@ export class Copilot {
} finally {
this.busy.set(false);
this.controller = null;
this.restoreAfterPageAction();
}
}

/**
* Get out of the way so the user can watch a tool operate the page.
*
* On a phone the panel is `fixed inset-0` — a full-screen opaque sheet — so a
* tool driving the UI underneath it would be completely invisible, which
* would defeat the point of driving the UI at all. Below the `sm` breakpoint
* it therefore drops to the orb for the rest of the turn and comes back when
* the turn ends. From `sm:` up the panel is already non-blocking, so nothing
* happens.
*
* Collapsing for the *turn* rather than per call is deliberate: a turn that
* files an expense and then approves it would otherwise flicker the panel
* shut and open between the two.
*/
collapseForPageAction(): void {
if (!this.open() || this.collapsed) return;
if (typeof window === 'undefined' || window.matchMedia === undefined) return;
// 40rem is Tailwind's `sm`, where the panel stops covering the page.
if (window.matchMedia('(min-width: 40rem)').matches) return;

this.collapsed = true;
this.open.set(false);
}

/** Put the panel back after a turn that collapsed it. */
private restoreAfterPageAction(): void {
if (!this.collapsed) return;
this.collapsed = false;
this.open.set(true);
}

/** Stop everything in flight. §3.2.6 wants this to feel immediate. */
stop(): void {
this.controller?.abort();
Expand All @@ -163,6 +200,7 @@ export class Copilot {
),
);
this.busy.set(false);
this.restoreAfterPageAction();
}

/** Called by the Confirm / Cancel buttons on a tool card. */
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/app/core/agent/fill-pacing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Pacing for a form an agent is filling in front of the user.
*
* The reason a tool drives the visible form instead of posting behind it is so
* a person can watch it happen. Setting every field and submitting in the same
* frame produces no frame in which the filled form is on screen — the result
* looks exactly like the invisible POST it replaced. A short pause between
* fields, and one before the save, is what makes it legible.
*
* Specs drive this at 0.
*/
export const AGENT_FILL_STAGGER_MS = 120;

/** A sleep that gives up promptly when the agent is stopped. */
export function agentPause(ms: number, signal?: AbortSignal): Promise<void> {
if (ms <= 0) return Promise.resolve();
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms);
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer);
reject(signal.reason ?? new Error('Aborted.'));
},
{ once: true },
);
});
}

/**
* Set one control the way a person would, so the screen actually repaints.
*
* Both `input` and `change` are dispatched: a `<select>` and a date field need
* `change`, and anything listening for typing needs `input`.
*/
export function setFieldValue(form: HTMLFormElement, name: string, value: string): void {
const field = form.elements.namedItem(name);
if (
!(
field instanceof HTMLInputElement ||
field instanceof HTMLSelectElement ||
field instanceof HTMLTextAreaElement
)
) {
return;
}
field.value = value;
field.dispatchEvent(new Event('input', { bubbles: true }));
field.dispatchEvent(new Event('change', { bubbles: true }));
}
Loading
Loading