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()andend()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). |