| name | normalize-currency |
| requires | ["define-workspace"] |
| description | Turn a set of amounts in different currencies into one auditable answer — either a single converted total carrying the exchange rate and as-of date behind it, or a clean per-currency breakdown — and never a blended figure. Use when a Well skill has totals spanning more than one currency, needs a rate from the `exchange_rates` root, or must state which date's rate a converted number used. Do not use when a Well tool already returned a converted figure, to fetch live market rates from outside Well, or to compute any business metric. |
Normalize Currency with Well
Purpose
Stop multi-currency results from becoming one meaningless number. Given amounts in several currencies, either convert them to one target currency — carrying the rate and the as-of date that produced each conversion — or report them per currency, and never silently add them together.
The rule this enforces is absolute: a figure that blends currencies is wrong, not approximate. The rule that makes it auditable is nearly as important and was previously written down in only one skill: a converted total means nothing unless the reader can see which rate, from which date, produced it.
When to use this skill
Use this skill when:
- A calling skill's results span more than one currency and it needs one total, or a defensible per-currency breakdown.
- A rate must be looked up in Well's
exchange_rates root for a currency pair as of a date.
- A converted figure has to state the rate and date behind it.
- A rate is missing for one currency in a set and the answer must stay honest about what it excluded.
When not to use this skill
Do not use this skill when:
- A Well tool already returned the converted figure.
well_get_cash_position and well_get_runway convert server-side and hand back the converted amount, the native amounts, and the rate applied. Re-deriving those totals here produces numbers that disagree with what the Well app shows the user. Read the tool's own fields instead — this is a hard exclusion, not a preference.
- The workspace is not resolved yet — run
define-workspace first and pass its workspace_id in.
- The user wants live market or mid-market rates from outside Well — this skill reads only what Well has synced.
- Everything is already in one currency. Say so and skip; there is nothing to normalize.
- The user wants a business metric (runway, exposure, revenue) — the data skills call this after computing theirs.
Inputs
The calling skill provides:
workspace_id — required. Comes from define-workspace.
amounts — the set to normalize, each an amount with its currency and a caller-chosen tag identifying the row it came from (a customer id, an invoice number, a balance date). Tags are how the caller gets row-level results back; without one, only per-currency subtotals are recoverable.
target_currency — what to convert to. Default: the workspace's identity.base_currency from define-workspace's hand-off. If that is null, ask rather than assuming a currency.
as_of — the date the rates should be read at. Default: today.
mode — auto (default: convert when a target currency is known, otherwise report per currency), convert (a single total is required), or per_currency (never convert, just group).
Tooling
Runs over Well's MCP server (https://api.wellapp.ai/v1/mcp, streamable HTTP). If the well_* tools are not in your toolset, the host has not added the Well MCP server yet — tell the user to add it at that URL, then retry. Required once it is added:
well_get_schema — call on exchange_rates before the first rate read in a session; field names and pair direction are connector-dependent.
well_query_records — read exchange_rates for the pairs and date needed.
Never fetch a rate from outside Well, and never invent one. A rate that is not in Well is a rate this skill does not have, and the honest output is an exclusion.
Composed skills. One atomic Well skill owns the step before this one — invoke it, don't reimplement it:
define-workspace — pins the workspace and supplies both the workspace_id and the identity.base_currency that defaults target_currency.
It ships with the well-skills plugin. This skill is also installable on its own, so step 1 carries the inline fallback to use when it's absent.
Workflow
-
Require the workspace. Take workspace_id from the caller and pass it on every call below. If the caller did not pass one, run define-workspace and take its hand-off; never pick a workspace here.
- If
define-workspace isn't installed, resolve inline: with no well_* tool, point the user at https://api.wellapp.ai/v1/mcp and stop; on an auth error run the OAuth/DCR flow and retry in the same turn; then take the single workspace or ask which to use.
-
Group by currency for the rate lookup, and keep every row. Sum the input amounts within each currency to learn which currencies are present — that is one rate lookup per currency rather than per row, and it means a missing rate later costs one currency rather than the whole set. Grouping is for rates, not for results: keep every tagged input row, because the rate you find for a currency is applied back to each of its rows in step 6. Collapsing rows here would leave a caller that ranks customers, runs a per-bill cumulative, or plots dated points unable to use the answer without redoing the conversion itself.
- One currency only is a shortcut only when there is nothing to convert. Take it — report the single total,
resolution: single_currency, no exchange_rates read — when that currency already equals target_currency, or when mode is per_currency. When the sole currency differs from the target and conversion was asked for, carry on through the conversion steps: a lone foreign currency is the ordinary case for fx-exposure, and returning its native subtotal as though it were the home-currency total would be a wrong number, not a shortcut.
-
Settle the target currency. Use the caller's target_currency; otherwise the workspace's identity.base_currency. If both are absent, do not pick the largest bucket or the first row's currency — ask, or fall back to per_currency mode and say why.
-
Read each rate, and record which date it came from. well_get_schema({ root: "exchange_rates" }) once per session, then for each non-target currency look up the pair as of as_of:
- An exact-date rate → use it, mark it exact.
- No exact-date rate → use the most recent rate at or before , and record that date. This is the rule that keeps a converted number defensible.
Output requirements
Return:
-
One line with the converted total, its currency, and the as-of date — or, in per_currency mode, the per-currency list with no total.
-
Every rate used, with its date and whether it was exact. A converted figure without its rate and date is not a finished answer.
-
Any excluded currency, with the reason, and the total marked partial.
-
The hand-off block, exactly these keys, so a calling skill can read it:
target_currency: <ISO code or null>
as_of: <YYYY-MM-DD>
converted_total: <number or null>
per_currency:
- currency: <ISO code>
native_amount: <number>
converted_amount: <number or null>
rate: <number or null>
rate_date: <YYYY-MM-DD or null>
rate_is_exact: <true|false>
converted:
- tag: <caller's row id>
currency: <ISO code>
native_amount: <number>
converted_amount: <number or null>
excluded: [{ currency: <ISO code>, }, ]
Do not return:
- A total that adds two currencies together.
- A converted figure without the rate and date behind it.
- A rate dated after
as_of, or one invented when Well had none.
- A total that quietly omits a currency Well had no rate for.
How this reaches the user. A Well MCP tool that ships a widget attaches
_meta.ui.resourceUri to its result, and the host decides whether to draw it. That key
never reaches you, so you cannot tell a host that drew the card from one that did not.
Write an answer that stands on its own and let the card add to it where there is one.
State the converted figures in text regardless — you cannot know whether anything drew them. What you must not add is a second rendering of what a card already shows.
Quality checks
Before finishing, verify:
- If
well_* tools were absent, the user was pointed at https://api.wellapp.ai/v1/mcp instead of a tool error.
workspace_id came from define-workspace (or the caller) and rode every call.
- Amounts were grouped per currency for the rate lookup only; every tagged row survived into
converted, so a missing rate cost one currency rather than the set and no caller has to redo the conversion.
- The single-currency shortcut was taken only when that currency already equalled
target_currency or mode was per_currency — never when conversion was requested for a lone foreign currency.
- The target currency came from the caller or the workspace's base currency — never from the largest bucket or the first row.
- Every rate was read from Well's
exchange_rates, dated at or before as_of, with the fallback date recorded and no future rate used.
- Pair direction was checked against the schema, so no rate was inverted.
- Every converted figure is reported with its rate and rate date; none is bare.
- A currency with no available rate was excluded explicitly, kept in the per-currency breakdown, and the total marked
partial.
- No figure was re-derived that
well_get_cash_position or well_get_runway had already converted.
- Every arithmetic figure in the answer was actually computed, not asserted — a stated total that does not equal its own inputs times its own rate teaches the wrong number.
- No total blends currencies, anywhere in the output.
Examples
Example request
accounts-receivable-aging calls with receivable subtotals in EUR 42,000 and USD 18,500, target_currency: EUR, as_of today. exchange_rates has a USD→EUR rate for yesterday but not today.
Expected behavior
Group per currency, then look up USD→EUR: no exact-date rate, so use yesterday's and record that date. Report "€59,020 as of 2026-08-19, using the USD→EUR rate of 0.92 from 2026-08-18", list both native subtotals, and hand off with rate_is_exact: false for USD. Do not reach for a later rate, and do not present the total as if it were struck at today's rate.
Example request
company-profile calls with invoice totals in EUR and in a currency exchange_rates has no row for at all.
Expected behavior
Convert what can be converted, exclude the unrated currency from the total with its reason, keep its native subtotal in per_currency, and mark partial: true. Say plainly that the total covers EUR only and which currency is missing — an unexplained smaller total reads as a complete one.
Example request
cash-position is about to report a total and considers calling this skill.
Expected behavior
It should not. well_get_cash_position already returned the converted total, each account's native amount, and the FX rate applied. Read those fields. Converting again here would produce a second number that disagrees with the Well app for the same workspace.
Example request
Every amount is already in GBP, and target_currency is GBP.
Expected behavior
Report the single GBP total, resolution: single_currency, no rate lookup, no exchange_rates read at all. Nothing to normalize is a valid and common answer.
Example request
fx-exposure calls with mode: convert, target_currency: EUR, and every amount in USD — one currency, and it is not the target.
Expected behavior
Do not take the single-currency shortcut. One currency does not mean nothing to convert: look up USD→EUR and return the converted EUR total with its rate and rate date. Returning the USD subtotal here would hand fx-exposure a foreign-currency figure labelled as home-currency exposure, which is a wrong number rather than a skipped step. This is the ordinary shape of a workspace with a single foreign currency.
Example request
rank-clients-by-ltv calls with paid revenue per customer — twelve rows, eight of them USD — and target_currency: EUR.
Expected behavior
One USD→EUR lookup, applied to each of the eight USD rows individually. Return converted with twelve entries carrying the callers' tags, plus per_currency with the two subtotals and the rate. Returning only two per-currency subtotals would leave the ranking impossible to build: the caller needs each customer's converted total to sort them and compute shares.