Integrations / Server-side TypeScript

Vercel AI SDK

Keep the decision beside the generation.

Wrap a server-side generateText call, then submit the application’s decision metadata. The generated text remains with your application.

vercel-ai-sdk.ts
import { generateText } from 'ai';
import { recordDecision, type DecisionMetadata, type RecourseEnv } from './record-decision';

export async function generateWithReceipt(
  options: Parameters<typeof generateText>[0],
  meta: DecisionMetadata,
  env: RecourseEnv,
) {
  const result = await generateText({
    ...options,
    telemetry: { isEnabled: false },
  });
  // Metadata comes from your application's decision policy, never generated text.
  const receipt = await recordDecision(env, meta);
  return { result, receipt };
}

Received: request acknowledged. Acknowledged is not durable. Durable: committed to the configured persistence layer. Sealed: included in a signed root with a receipt. The API wire values remain accepted, durable and sealed. Searchable-through is a separate index coverage watermark: not available in this preview.

Development signer means integrity under a published dev key and no independently trusted time.

The current AI SDK uses telemetry, with isEnabled: false to disable telemetry for this call. A completed generation does not imply an allow outcome: the metadata must come from your application’s decision policy. This wrapper records completion; place recordDecision in the actual policy step when that is a separate operation.

Choose what enters the record

All five SDK examples use the same recordDecision helper. Supply RECOURSE_URL, RECOURSE_TENANT_ID and RECOURSE_API_KEY from the server environment. It sends POST /v1/adr with Bearer authentication, tenant scope and the event’s idempotency key.

Public metadata uses opaque subject, actor and action tokens; token syntax alone does not make an identifier nonidentifying. The helper always sends private_fields: {}.

Read the shared transport and retry handling
record-decision.ts
/** Server-only ADR transport. Documentation example; no top-level network call. */
export type RecourseEnv = {
  RECOURSE_URL: string;
  RECOURSE_TENANT_ID: string;
  RECOURSE_API_KEY: string;
};

export type DecisionMetadata = {
  eventId: string;
  occurredAt: string;
  subjectId: string;
  actorId: string;
  action: string;
  outcome: 'allow' | 'deny' | 'defer' | 'error' | 'abstain' | 'other';
};

export class RecourseHTTPError extends Error {
  constructor(readonly status: number, readonly retryAfterSeconds?: number) {
    super(`Recourse request failed (HTTP ${status})`);
    this.name = 'RecourseHTTPError';
  }
}

const token = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const outcomes = new Set(['allow', 'deny', 'defer', 'error', 'abstain', 'other']);

export function decisionPayload(meta: DecisionMetadata) {
  if (![meta.eventId, meta.subjectId, meta.actorId, meta.action].every(v => typeof v === 'string' && token.test(v)) ||
      !outcomes.has(meta.outcome) ||
      !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(meta.occurredAt) ||
      !Number.isFinite(Date.parse(meta.occurredAt)) || new Date(meta.occurredAt).toISOString() !== meta.occurredAt) {
    throw new Error('Invalid public decision metadata');
  }
  return {
    schema_version: '1.0' as const,
    occurred_at: meta.occurredAt,
    subject_id: meta.subjectId,
    actor: { type: 'agent' as const, id: meta.actorId },
    decision: { action: meta.action, outcome: meta.outcome },
    private_fields: {},
  };
}

export async function recordDecision(
  env: RecourseEnv,
  meta: DecisionMetadata,
  transport: typeof fetch = fetch,
  pause: (ms: number) => Promise<void> = ms => new Promise(resolve => setTimeout(resolve, ms)),
): Promise<{ adrId: string; ack: 'accepted' | 'durable' | 'sealed' }> {
  let base: URL;
  try { base = new URL(env.RECOURSE_URL); } catch { throw new Error('Invalid Recourse environment'); }
  if ((base.protocol !== 'https:' && !(base.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname))) ||
      base.username || base.password || base.search || base.hash ||
      typeof env.RECOURSE_TENANT_ID !== 'string' || !token.test(env.RECOURSE_TENANT_ID) ||
      typeof env.RECOURSE_API_KEY !== 'string' || !env.RECOURSE_API_KEY || /[\r\n]/.test(env.RECOURSE_API_KEY)) {
    throw new Error('Invalid Recourse environment');
  }
  // Keep the same event ID, timestamp and serialized payload across all retries.
  const body = JSON.stringify(decisionPayload(meta));
  for (let attempt = 0; attempt < 3; attempt++) {
    let response: Response;
    try {
      response = await transport(new URL('/v1/adr', base), {
        method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${env.RECOURSE_API_KEY}`,
          'X-Tenant-ID': env.RECOURSE_TENANT_ID,
          'Idempotency-Key': meta.eventId,
        },
        body,
      });
    } catch {
      if (attempt === 2) throw new Error('Recourse transport unavailable; retain this event for retry');
      await pause(250 * 2 ** attempt);
      continue;
    }
    const retryHeader = response.headers.get('Retry-After');
    const retryAfter = retryHeader && /^\d+$/.test(retryHeader) ? Number(retryHeader) : undefined;
    if (!response.ok) {
      await response.body?.cancel();
      // Quota, auth, conflict and schema errors require caller action; do not retry them here.
      if (![408, 500, 502, 503, 504].includes(response.status) || attempt === 2 || (retryAfter ?? 0) > 30) {
        throw new RecourseHTTPError(response.status, retryAfter);
      }
      await pause(Math.max(250 * 2 ** attempt, (retryAfter ?? 0) * 1_000));
      continue;
    }
    let result: unknown;
    try { result = await response.json(); } catch { throw new Error('Invalid Recourse acknowledgement; retain this event for retry'); }
    const record = (result as { records?: { adr_id?: unknown; ack?: unknown }[] })?.records?.[0];
    if (!record || typeof record.adr_id !== 'string' || !/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/.test(record.adr_id) ||
        !['accepted', 'durable', 'sealed'].includes(String(record.ack))) {
      throw new Error('Invalid Recourse acknowledgement; retain this event for retry');
    }
    return { adrId: record.adr_id, ack: record.ack as 'accepted' | 'durable' | 'sealed' };
  }
  throw new Error('Recourse transport unavailable; retain this event for retry');
}

Keep the decision through retries

Keep the original metadata in a durable application outbox. Retry the ADR write after a transport failure without rerunning a completed provider action. The helper makes up to three attempts for transient errors, including the initial request; authentication, schema, conflict and quota failures return to the caller. It never logs response bodies or credentials.

After the request is acknowledged, poll the record status through the normal minute boundary. Fetch a sealed receipt and verify it with an explicit trust anchor. A Durable response means persistence, while Sealed means a receipt is available for verification.

R0 examples are source-checked and syntax-checked, with the shared HTTP transport tested using local mocks. Provider packages are not installed or executed here; full SDK type compatibility and live provider integration remain unqualified. The ADR schema is provisional pending the unavailable D-002 source.

API references checked 5 September 2026: AI SDK text generation · AI SDK telemetry. Documentation examples only; no provider call or live Collector export was executed.