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.
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
/** 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.