Docs / SDK guide — instrument an agent

SDK — instrumenting an agent

@paraph/sdk is the drop-in TypeScript client. It records an agent's run as a tamper-evident event stream and provides the human-approval gate.

Setup

npm install @paraph/sdk
import { paraph } from "@paraph/sdk";

const q = paraph({
  apiKey: process.env.PARAPH_API_KEY!,
  run: "loan-4821", // your run id — the customer-facing identifier
  agent: "underwriter-agent",
  baseUrl: "https://your-paraph-host", // defaults to the hosted API
});

Recording events

Recording is non-blocking and order-preserving — calls return immediately while the client drains a queue in the background, retrying on failure without reordering.

q.message("Applicant #4821 requests a €40,000 loan");
q.toolCall("pull_credit_report", "score=690 · DTI=38%", { metadata: { bureau: "Experian" } });
q.modelCall("underwriter-agent", "Within policy but above the auto-approve ceiling.", {
  metadata: { model: "claude-opus-4-8", cost_usd: 0.014 },
});
q.decision("recommendation", "approve, pending human sign-off");

await q.flush(); // await delivery — call at run end

Helpers map to event kinds: message, modelCall (model_call), toolCall (tool_call), decision. q.record({ kind, … }) is the general form.

The gate (human approval)

q.gate() opens a pending approval and blocks until a human resolves it. The request and the resolution are recorded as decision events in the run's record.

const decision = await q.gate({
  action: "Approve €40,000 consumer loan for applicant #4821",
  policy: "block", // wait indefinitely for a human
});

if (decision.approved) {
  await disburseLoan();
} else {
  await declineLoan(decision.note);
}

q.gate() flushes queued events first (so they precede the gate in the record), opens the gate, then waits.

Options

Field Default Meaning
action Human-readable description of the high-risk action.
policy expire on timeout block (wait forever) · auto-deny (reject on timeout).
timeoutMs 300000 Max wait before the policy applies. Ignored for block.
pollMs 1000 Poll interval while waiting.

Returns GateDecision: { status: "approved" | "rejected" | "expired", approved: boolean, reviewer?: string, note?: string }.

Full example

import { paraph } from "@paraph/sdk";

async function underwrite(application: LoanApplication) {
  const q = paraph({ apiKey: process.env.PARAPH_API_KEY!, run: application.id, agent: "underwriter-agent" });

  q.message(`Applicant #${application.id} requests €${application.amount}`);
  const report = await pullCreditReport(application);
  q.toolCall("pull_credit_report", JSON.stringify(report), { metadata: { bureau: report.bureau } });

  if (application.amount > 25_000) {
    // above the auto-approve ceiling — require a human
    const decision = await q.gate({ action: `Approve €${application.amount} loan`, policy: "block" });
    if (!decision.approved) {
      await q.flush();
      return decline(application, decision.note);
    }
  }

  await disburse(application);
  q.message("Loan disbursed");
  await q.flush();
}

Delivery guarantees — stated plainly

The SDK is honest about what it does and doesn't guarantee. Current semantics (v0.2):

  • Order-preserving, non-blocking. record() never blocks the agent; events deliver in order from an in-process queue.
  • No duplicates, ever. Every event carries a generated idempotency key; a retried delivery (including an ambiguous network failure after the server committed) replays the original append server-side instead of appending twice.
  • Self-healing retries. A failed delivery retries automatically with capped exponential backoff — it does not wait for your next call.
  • flush() and end() tell the truth. They raise/throw if events remain undelivered after retries. Silence means delivered — never "probably delivered".
  • The queue is in-process memory. It does not survive a crash or a serverless freeze. Call flush() at meaningful boundaries (end of request, before the gate — gate() does this itself). A durable local spool is on the roadmap for regulated deployments.
  • Gate timeouts are recorded server-side. If that recording fails after retries, the decision is indeterminate (recorded: false, not approved) — the SDK never invents a final-looking answer that isn't in the record. Reconcile indeterminate gates in the control room.

Closing a run

await q.end();          // flushes, then marks the run complete
await q.end("error");   // ...or closed with an error status

In Python the context manager does this automatically — a clean exit closes the run as complete, an exception closes it as error (and re-raises):

with Paraph(api_key=..., run="loan-4821") as q:
    ...  # any raise in here → the run's record ends with status "error"

Config reference

Field Required Meaning
apiKey yes A Paraph API key (sent as a Bearer token).
run yes The run id this client records to.
baseUrl no Ingestion base URL (defaults to the hosted API).
agent / framework / model no Run metadata, set when the run is created.
onError no Observe background-delivery errors.
fetch / sleep no Injectable transport + delay (for tests).