Guides

TypeScript / Node

The MCP package is convenient, but it isn't required. The REST API is small enough that a hand-rolled client is often the right call — especially for headless agents or worker queues.

Prerequisites

  • Node ≥ 20 (uses built-in fetch).
  • AC_AGENT_KEY in env. Don’t hardcode it — load it from process.env or a secret store.

Drop-in types

Generate from the OpenAPI spec with openapi-typescript, or hand-roll the shapes you need. The minimum:

types.ts
export type ScopeName = 'read' | 'validate' | 'propose';

export interface TradePayload {
  action: 'buy' | 'sell';
  assetSymbol: string;
  assetType: 'crypto' | 'tradfi';
  amountUsd: number;
  expectedSlippagePct?: number;
}

export interface Violation {
  rule: string;
  message: string;
  limit?: number;
  actual?: number;
}

export interface ValidateResponse {
  allowed: boolean;
  reason: string;
  violations: Violation[];
  ipsVersion: number | null;
  snapshot: Record<string, number | string | null>;
  checkedAt: string;
}

export interface ProposeResponse {
  proposed: boolean;
  idempotent?: boolean;
  serviceRequestId?: number;
  decision?: ValidateResponse;
  message: string;
}

Minimal client

ac-client.ts
import { randomUUID } from 'crypto';
import type { ProposeResponse, TradePayload, ValidateResponse } from './types';

const BASE =
  process.env.AC_API_BASE ?? 'https://api.advisorscrypto.com';
const KEY = process.env.AC_AGENT_KEY;
if (!KEY) throw new Error('AC_AGENT_KEY is required');

async function call<T>(
  method: 'GET' | 'POST',
  path: string,
  init: { body?: unknown; idempotencyKey?: string } = {},
): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${KEY}`,
      'Content-Type': 'application/json',
      ...(init.idempotencyKey
        ? { 'Idempotency-Key': init.idempotencyKey }
        : {}),
    },
    body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
  });
  const text = await res.text();
  const parsed = text ? JSON.parse(text) : null;
  if (!res.ok) {
    const msg =
      parsed && typeof parsed === 'object' && 'message' in parsed
        ? String(parsed.message)
        : `HTTP ${res.status}`;
    throw new Error(`AC API ${method} ${path} → ${msg}`);
  }
  return parsed as T;
}

export const ac = {
  getMandate: () => call<unknown>('GET', '/api/agent/v1/mandate'),
  validate: (payload: TradePayload) =>
    call<ValidateResponse>('POST', '/api/agent/v1/policy/validate', {
      body: payload,
    }),
  propose: (payload: TradePayload & { rationale?: string }) =>
    call<ProposeResponse>('POST', '/api/agent/v1/trades/propose', {
      body: payload,
      idempotencyKey: randomUUID(),
    }),
};

Putting it together

rebalance.ts
import { ac } from './ac-client';

async function rebalanceIfDrift() {
  const mandate = (await ac.getMandate()) as {
    utilization: { cryptoUsedPct: number };
  };

  // Sleeve > 70% utilization means we're > 27.5% in a 0.10–0.35 band.
  if (mandate.utilization.cryptoUsedPct > 0.7) {
    const trim = await ac.propose({
      action: 'sell',
      assetSymbol: 'BTC',
      assetType: 'crypto',
      amountUsd: 8500,
      rationale: 'Rebalance crypto sleeve toward 20% target.',
    });

    if (!trim.proposed) {
      console.warn('Trim blocked:', trim.decision?.violations);
      return;
    }
    console.log('Pending attestation:', trim.serviceRequestId);
  }
}

Useful patterns

  • Always validate first if you can. propose already validates, but a separate validate call is free and lets you log the decision without creating a user-facing attestation when the proposal would be blocked anyway.
  • Backoff on 429. The global throttle is 100 req/min — interactive agents don’t hit it, but backfill scripts can. Treat 429 as a signal to sleep, not an error.
  • Pin AC_API_BASE for preview envs. We sometimes run preview backends. Setting AC_API_BASE from env lets the same client target prod or preview without code changes.
Last updated 2026-06-15