AI

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.


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

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

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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 fieldWhat it would solveWorkaround today
outcome or statusOne stable way to tell an answer from a clarification or refusal.HTTP status plus data.length plus the text in answer.
kind on each datumTyped rendering without field inspection.classifyDatum above.
citationsPer-number provenance.Re-query the matching REST endpoint. See AI grounding.
trace or toolCalls[].nameKnowing which tool ran and with what inputs.toolCalls gives only a count. Server logs are not exposed.
usageToken accounting in the response.Not available. Use your own request counts.
requestIdCorrelating a support ticket with a server log.Capture the time, query, status, and response headers.
model or provider in the bodyReproducing an answer on a specific model.x-currencycore-ai-provider and x-currencycore-ai-byok headers.
An error code on a 200 non-answerBranching 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.