Integrations / Server-side TypeScript

Claude Agent SDK

Record the terminal result of a query.

The Agent SDK query function yields events asynchronously. This wrapper records a terminal result and maps an unsuccessful run to the public error outcome.

claude-agent-sdk.ts
import { query } from '@anthropic-ai/claude-agent-sdk';
import { recordDecision, type DecisionMetadata, type RecourseEnv } from './record-decision';

export async function queryWithReceipt(
  input: Parameters<typeof query>[0],
  meta: DecisionMetadata,
  env: RecourseEnv,
) {
  const stream = query({
    ...input,
    options: { ...input.options, persistSession: false },
  });
  for await (const event of stream) {
    if (event.type === 'result') {
      // A successful run is not necessarily an allow decision.
      const outcome = event.subtype === 'success' && !event.is_error ? meta.outcome : 'error';
      return await recordDecision(env, { ...meta, outcome });
    }
  }
  throw new Error('Agent ended without a terminal result');
}

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.

Session persistence is disabled in this example. No event body or session identifier is recorded. A successful query retains the application’s decision outcome; success is not automatically an allow decision. Configure the query’s tools and permissions in your own server environment.

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: Claude Agent SDK TypeScript reference. Documentation examples only; no provider call or live Collector export was executed.