# AI Response Schema

> TypeScript types for the current GET /v1/ai response and all 12 data shapes, plus a safe rendering pattern that does not rely on a discriminator.

Canonical: https://currency-core.com/docs/ai-response-schema/

---
The current `/v1/ai` response is a small envelope plus a dynamic `data` array.
There is no `outcome`, no `kind`, no `citations`, no `trace`, and no `usage`
block. These types describe exactly what the route returns today.

> **These are documentation types, not an importable SDK.** The project does not
> publish a client type package for `/v1/ai`. Copy the types into your own code if
> they are useful, and keep them aligned with the response you actually receive.

## Envelope

```ts
export interface AiResponse {
  /** The trimmed question you sent. */
  query: string;

  /** Plain text, no Markdown. */
  answer: string;

  /** Zero or more result objects. Can mix shapes in one response. */
  data: AiResponseDatum[];

  /**
   * Successful billable tool calls behind the answer.
   * Optional in the shared type, but present on every 200 response today.
   */
  toolCalls?: number;

  /**
   * Present only when BYOK is active for the organization.
   * true = your provider answered, false = native fallback answered.
   */
  byok?: boolean;
}

export type AiResponseDatum =
  | AiConvertDatum
  | AiRatesDatum
  | AiHistoryAnalysisDatum
  | AiPppAnalysisDatum
  | AiVolatilityDatum
  | AiCorrelationDatum
  | AiDrawdownDatum
  | AiSafeHavenDatum
  | AiMeanReversionDatum
  | AiRiskAdjustedDatum
  | AiStreaksDatum
  | AiStabilityDatum;
```

On a `200`, branch on `data.length` first:

- `data.length > 0`: normal answer with structured results.
- `data.length === 0`: clarification, no-data explanation, upgrade prompt,
  no-tool informational answer, or a too-large analysis notice. All are still
  `200`.

## Shared result types

```ts
export interface AiStats {
  min: number;
  max: number;
  avg: number;
}

/** A downsampled trend: period label to average rate. */
export type AiSeries = Record<string, number>;
```

`stats` and `series` are optional because the AI engine does not compute them for
every request. Do not assume they exist.

## Convert datum

Returned for a conversion, a single-pair rate (usually `amount: 1`), or a
PPP-adjusted conversion. Recognizable by `pppApplied`.

```ts
export interface AiConvertDatum {
  from: string;
  to: string;
  amount: number;
  /** PPP-adjusted amount when pppApplied is true, otherwise the FX result. */
  result: number;
  /** result / amount. With PPP this is the implied PPP ratio, not the market FX rate. */
  rate: number;
  pppApplied: boolean;
  /** Rate reference date, YYYY-MM-DD. Can be earlier than the request date. */
  asOf: string;
  fromCountry?: string;
  toCountry?: string;
  pppYear?: number;
  pppSource?: string;
}
```

## Rates datum

Returned for a multi-currency list against one base. Recognizable by `rates`.

```ts
export interface AiRatesDatum {
  base: string;
  asOf: string;
  /** Units of each currency per one unit of base. */
  rates: Record<string, number>;
}
```

## History analysis datum

Trend, comparison, movers, or multi-currency report. Growth+ only. Recognizable
by `results[]` entries containing `strengthenedPct`.

```ts
export interface AiHistoryAnalysisDatum {
  base: string;
  from: string;
  to: string;
  mode: "compare" | "movers";
  results: AiHistoryAnalysisResult[];
}

export interface AiHistoryAnalysisResult {
  currency: string;
  startRate: number | null;
  endRate: number | null;
  /** Percent change in the quoted rate. */
  changePct: number | null;
  /** Appreciation against base. Positive means stronger. */
  strengthenedPct: number | null;
  /** Often absent for AI requests. */
  stats?: AiStats;
  /** Downsampled trend, when present. */
  series?: AiSeries;
  /** Annualized volatility, present in compare mode for report-style answers. */
  volatility?: number | null;
}
```

Use `strengthenedPct`, not `changePct`, for direction. They have opposite signs
for a non-USD currency.

## PPP analysis datum

PPP factor change over time. Growth+ only. `from` and `to` are years, not dates.
Recognizable by numeric `from`/`to` and `results[]` entries with `startFactor`.

```ts
export interface AiPppAnalysisDatum {
  from: number;
  to: number;
  mode: "compare" | "movers";
  results: AiPppAnalysisResult[];
}

export interface AiPppAnalysisResult {
  country: string;
  startYear: number;
  endYear: number;
  startFactor: number | null;
  endFactor: number | null;
  /** Percent change in the PPP factor. Positive means the factor rose. */
  changePct: number | null;
  /** True when endFactor is an IMF projection, not an actual observation. */
  projected: boolean;
  stats?: AiStats;
  series?: AiSeries;
}
```

A rising factor is not proof of higher purchasing power or living standards. It
is a conversion coefficient.

## Volatility datum

Growth+ only. Recognizable by `results[]` entries with `volatility` and
`observations`, and no `annualReturn` or `riskAdjusted`.

```ts
export interface AiVolatilityDatum {
  base: string;
  from: string;
  to: string;
  mode: "compare" | "ranking";
  results: {
    currency: string;
    /** Annualized volatility in percent, or null. */
    volatility: number | null;
    observations: number;
  }[];
}
```

## Correlation datum

Growth+ only. Recognizable by `results[]` entries with `correlation`.

```ts
export interface AiCorrelationDatum {
  base: string;
  from: string;
  to: string;
  results: {
    currency: string;
    correlation: number | null;
    observations: number;
  }[];
}
```

## Drawdown datum

Growth+ only. Recognizable by `results[]` entries with `maxDrawdownPct` and no
top-level `crisis`.

```ts
export interface AiDrawdownDatum {
  base: string;
  from: string;
  to: string;
  results: {
    currency: string;
    /** Peak-to-trough decline. Negative or zero. */
    maxDrawdownPct: number | null;
    peakDate?: string;
    troughDate?: string;
    recoveryDate?: string | null;
    recoveryDays?: number | null;
  }[];
}
```

## Safe haven datum

Growth+ only. Recognizable by the top-level `crisis` field.

```ts
export interface AiSafeHavenDatum {
  base: string;
  from: string;
  to: string;
  /** Reference crisis label for crisisReturnPct. */
  crisis: string;
  results: {
    currency: string;
    /** Higher means safer. */
    score: number | null;
    volatility: number | null;
    maxDrawdownPct: number | null;
    crisisReturnPct: number | null;
  }[];
}
```

## Mean reversion datum

Growth+ only. Recognizable by `results[]` entries with `deviationPct`.

```ts
export interface AiMeanReversionDatum {
  base: string;
  from: string;
  to: string;
  results: {
    currency: string;
    /** Higher means more mean-reverting. */
    score: number | null;
    deviationPct: number | null;
    reversionFrequency: number | null;
    halfLifeDays: number | null;
  }[];
}
```

## Ai risk adjusted datum

Growth+ only. Recognizable by `results[]` entries with `riskAdjusted`. Also
contains `volatility`, so check for `riskAdjusted` before treating an entry as a
volatility result.

```ts
export interface AiRiskAdjustedDatum {
  base: string;
  from: string;
  to: string;
  mode: "compare" | "ranking";
  results: {
    currency: string;
    /** Annualized appreciation against base, in percent. */
    annualReturn: number | null;
    /** Annualized volatility, in percent. */
    volatility: number | null;
    /** annualReturn / volatility. Higher is better. */
    riskAdjusted: number | null;
  }[];
}
```

## Ai streaks datum

Growth+ only. Recognizable by `results[]` entries with `maxStreakYears`.

```ts
export interface AiStreaksDatum {
  base: string;
  from: string;
  to: string;
  mode: "compare" | "ranking";
  results: {
    currency: string;
    maxStreakYears: number;
    currentStreakYears: number;
    streakFrom: number | null;
    streakTo: number | null;
  }[];
}
```

## Ai stability datum

Growth+ only. Recognizable by the top-level `bandPct` field.

```ts
export interface AiStabilityDatum {
  base: string;
  from: string;
  to: string;
  mode: "compare" | "ranking";
  /** The band used, for example 5 for plus or minus 5 percent. */
  bandPct: number;
  results: {
    currency: string;
    /** Maximum absolute deviation from the window's first value, in percent. */
    maxDeviationPct: number | null;
    /** True when the currency never strayed beyond the band. */
    withinBand: boolean;
    /** Net annualized change against base, in percent. */
    annualReturn: number | null;
  }[];
}
```

## Safe rendering pattern

Do not switch on a field that does not exist. Detect the shape from its actual
fields, in an order that resolves overlaps. Safe haven, risk-adjusted, and
stability all contain fields that also appear in another shape, so test the more
specific shape first.

```ts
export type DatumKind =
  | "convert"
  | "rates"
  | "history_analysis"
  | "ppp_analysis"
  | "volatility"
  | "correlation"
  | "drawdown"
  | "safe_haven"
  | "mean_reversion"
  | "risk_adjusted"
  | "streaks"
  | "stability"
  | "empty_results"
  | "unknown";

export function classifyDatum(d: AiResponseDatum): DatumKind {
  if ("rates" in d) return "rates";
  if ("pppApplied" in d) return "convert";
  if ("crisis" in d) return "safe_haven";
  if ("bandPct" in d) return "stability";

  if (!("results" in d) || d.results.length === 0) return "empty_results";
  const row = d.results[0]!;

  if ("correlation" in row) return "correlation";
  if ("riskAdjusted" in row) return "risk_adjusted";
  if ("maxStreakYears" in row) return "streaks";
  if ("deviationPct" in row) return "mean_reversion";
  if ("strengthenedPct" in row) return "history_analysis";
  if ("startFactor" in row) return "ppp_analysis";
  if ("maxDrawdownPct" in row) return "drawdown";
  if ("volatility" in row) return "volatility";

  return "unknown";
}
```

Then render each kind with its own component. The cast is needed because the
payload has no discriminator, so TypeScript cannot narrow the union from the
classifier's return value alone.

```ts
export function renderDatum(d: AiResponseDatum): string {
  switch (classifyDatum(d)) {
    case "convert": {
      const c = d as AiConvertDatum;
      return `${c.amount} ${c.from} = ${c.result} ${c.to} (${c.asOf})`;
    }
    case "rates": {
      const r = d as AiRatesDatum;
      return `1 ${r.base} = ${Object.entries(r.rates)
        .map(([code, rate]) => `${rate} ${code}`)
        .join(", ")} (${r.asOf})`;
    }
    case "history_analysis": {
      const h = d as AiHistoryAnalysisDatum;
      return h.results
        .map((row) => `${row.currency}: ${row.strengthenedPct ?? "n/a"}% vs ${h.base}`)
        .join("; ");
    }
    case "ppp_analysis": {
      const p = d as AiPppAnalysisDatum;
      return p.results
        .map((row) => `${row.country}: PPP factor ${row.changePct ?? "n/a"}%`)
        .join("; ");
    }
    case "volatility": {
      const v = d as AiVolatilityDatum;
      return v.results
        .map((row) => `${row.currency}: volatility ${row.volatility ?? "n/a"}`)
        .join("; ");
    }
    case "correlation": {
      const c = d as AiCorrelationDatum;
      return c.results
        .map((row) => `${row.currency}: correlation ${row.correlation ?? "n/a"}`)
        .join("; ");
    }
    case "drawdown": {
      const dd = d as AiDrawdownDatum;
      return dd.results
        .map((row) => `${row.currency}: drawdown ${row.maxDrawdownPct ?? "n/a"}%`)
        .join("; ");
    }
    case "safe_haven": {
      const s = d as AiSafeHavenDatum;
      return s.results
        .map((row) => `${row.currency}: safe-haven score ${row.score ?? "n/a"}`)
        .join("; ");
    }
    case "mean_reversion": {
      const m = d as AiMeanReversionDatum;
      return m.results
        .map((row) => `${row.currency}: mean-reversion score ${row.score ?? "n/a"}`)
        .join("; ");
    }
    case "risk_adjusted": {
      const ra = d as AiRiskAdjustedDatum;
      return ra.results
        .map((row) => `${row.currency}: risk-adjusted ${row.riskAdjusted ?? "n/a"}`)
        .join("; ");
    }
    case "streaks": {
      const st = d as AiStreaksDatum;
      return st.results
        .map((row) => `${row.currency}: longest streak ${row.maxStreakYears} years`)
        .join("; ");
    }
    case "stability": {
      const sb = d as AiStabilityDatum;
      return sb.results
        .map((row) => `${row.currency}: within ${sb.bandPct}% band: ${row.withinBand}`)
        .join("; ");
    }
    case "empty_results":
      return "No structured results.";
    case "unknown":
      return "Unrecognized data shape. Render raw JSON in a debug view.";
  }
}
```

The `unknown` branch matters. A future response can add a shape before these
types are updated. Keep the raw payload available in a debug view rather than
dropping it.

## What is missing today

> The current response cannot describe itself. There is no outcome, no datum
> kind, no citation, no trace, no usage block, and no request ID. The table below
> lists each gap and the workaround that works against the API as it exists now.

These are not in the current response. Do not code against them, and do not
fabricate them from headers.

| Missing field | What it would solve | Workaround today |
|---|---|---|
| `outcome` or `status` | One stable way to tell an answer from a clarification or refusal. | HTTP status plus `data.length` plus the text in `answer`. |
| `kind` on each datum | Typed rendering without field inspection. | `classifyDatum` above. |
| `citations` | Per-number provenance. | Re-query the matching REST endpoint. See [AI grounding](/docs/ai-grounding/). |
| `trace` or `toolCalls[].name` | Knowing which tool ran and with what inputs. | `toolCalls` gives only a count. Server logs are not exposed. |
| `usage` | Token accounting in the response. | Not available. Use your own request counts. |
| `requestId` | Correlating a support ticket with a server log. | Capture the time, query, status, and response headers. |
| `model` or `provider` in the body | Reproducing an answer on a specific model. | `x-currencycore-ai-provider` and `x-currencycore-ai-byok` headers. |
| An error code on a `200` non-answer | Branching without reading prose. | Inspect `answer` and `data`. |

The route may add fields in a future version. Until then, ignore unknown fields
rather than rejecting the response, so a compatible addition does not break your
parser.

## Related

- [AI endpoint reference](/docs/ai-endpoint/)
- [AI quickstart](/docs/ai-quickstart/)
- [AI prompt guide](/docs/ai-prompt-guide/)
- [AI grounding](/docs/ai-grounding/)
- [AI errors](/docs/ai-errors/)
