@pliuz/sdk
v0.5.0
Published
Human-in-the-loop approval gates for AI agents — pause function calls for human review, then resume or abort.
Maintainers
Readme
@pliuz/sdk — TypeScript SDK
Human-in-the-loop approval gates for AI agents. Wrap any function in
gated()to pause execution, route the call to a human approver via Pliuz, then resume — or abort — based on the decision. Every call is audited.
import { gated } from '@pliuz/sdk'
const issueRefund = gated(
{
policy: 'refund',
redact: ['customer.ssn'],
toolArgs: (customerId: string, amountCents: number) => ({
customer_id: customerId,
amount_cents: amountCents,
}),
},
async (customerId: string, amountCents: number) => {
return stripe.refunds.create({ customer: customerId, amount: amountCents })
},
)
// Pauses. A human gets a Slack card. They click approve.
// Then your code runs — and Pliuz records the outcome.
const result = await issueRefund('cus_123', 5000)Install
npm install @pliuz/sdk
# or, with a framework adapter
npm install @pliuz/sdk ai # Vercel AI SDK
npm install @pliuz/sdk @anthropic-ai/claude-agent-sdk # Claude Agent SDK
npm install @pliuz/sdk @openai/agents # OpenAI Agents SDKRequires Node ≥ 18.17 (uses native fetch + AbortController).
Quickstart
1. Get an API key
Sign up at pliuz.com, create an agent in the dashboard, copy the key.
2. Set the env var
export PLIUZ_API_KEY=pli_live_...3. Gate a function
import { gated } from '@pliuz/sdk'
const runSql = gated(
{ policy: 'risky_query' },
async (query: string) => db.execute(query),
)
// Blocks until a human approves (or rejects, or it expires).
const rows = await runSql("DELETE FROM users WHERE created_at < '2024-01-01'")That's it. Pliuz handles the rest: idempotency, retries, audit logging, hash-chained event log.
What gated() does
yourFn(...args) yourFn(...args)
│ │
▼ ▼
┌──────────┐ POST /approvals ┌──────────┐
│ gated │ ───────────────► ┌─────────────┐ │ gated │
│ wrapper │ │ Pliuz API │ │ wrapper │
│ │ ◄─────────────── └─────────────┘ │ │
└──────────┘ status=pending ▲ └──────────┘
│ │ │
│ poll every 2s │ ▼
│ ────────────────────────────────► humans ┌──────────┐
▼ │ │ original │
┌──────────┐ status=approved │ │ fn() │
│ execute │ ◄──────────────────────────┘ └──────────┘
│ original │ │
│ fn() │ POST /:id/execution ▼
└──────────┘ ─────────────────────────► audit log
│
▼
resultFeatures
- Zero dependencies — uses native
fetch+AbortController - Dual ESM/CJS — works in any modern Node project
- First-class TypeScript — fully typed, including narrow error subclasses
- Idempotency via deterministic key hashing — safe to retry from anywhere
- Client-side redaction — sensitive fields never leave your process plaintext (applied to tool args AND the execution excerpt)
- Bounded waiting via
timeoutMs(first-classAbortSignalcancellation is on the roadmap — not shipped yet) - Human edits honored — if an approver edits the args, the edited args execute (or a typed error is thrown), never the original ones
- Single-shot execution reporting — awaited before your call returns, closing the audit loop reliably on serverless
- Typed errors —
PliuzRejectedError,PliuzApprovalExpiredError,PliuzPolicyError, etc. - Framework adapters —
gatedTool()wraps the framework's tool so existing agents gate transparently. Dedicated adapters for the Vercel AI SDK (/adapters/ai), the Claude Agent SDK (/adapters/claude-agent), and the OpenAI Agents SDK (/adapters/openai-agents) — any other framework still works viagated()on your own functions
API
gated() — the headline
import { gated } from '@pliuz/sdk'
const wrapped = gated(
{
policy: 'refund', // Pliuz policy slug (optional)
redact: ['customer.ssn'], // Dotted paths to strip BEFORE sending
timeoutMs: 300_000, // Max polling duration (5 min default)
pollIntervalMs: 2000, // Time between status checks
toolName: 'custom_name', // Override fn.name
toolArgs: (...args) => ({ ... }), // Map positional args → tool_args object
client: pliuz, // Reuse a client (default: lazy from env)
contextMessages: ['extra info for the human'],
sessionId: 'trace-abc',
originator: { type: 'user', id: 'user-42' },
},
async (...args) => { ... },
)Why toolArgs: JS lacks named-parameter introspection. By default, all positional args go to { args: [...] }. Provide a toolArgs mapper to get cleaner audit logs.
Errors
import {
PliuzError, // base
PliuzApiError, // any 4xx/5xx from Pliuz API
PliuzAuthError, // 401 — bad/missing key
PliuzForbiddenError, // 403 — agent mismatch
PliuzNotFoundError, // 404
PliuzConflictError, // 409 — duplicate execution report
PliuzValidationError, // 400 — invalid body
PliuzPolicyError, // 422 — no policy matched
PliuzRateLimitError, // 429
PliuzServerError, // 5xx
PliuzNetworkError, // connection failed
PliuzTimeoutError, // request timed out
PliuzRejectedError, // human said no
PliuzApprovalExpiredError, // SLA expired before human decided
PliuzApprovalTimeoutError, // SDK polling gave up
} from '@pliuz/sdk'
try {
await issueRefund(...)
} catch (e) {
if (e instanceof PliuzRejectedError) {
console.log('Human rejected:', e.reason)
return
}
if (e instanceof PliuzApprovalTimeoutError) {
// Poll later via client.getApproval(e.approvalId)
}
throw e
}Low-level client
import { PliuzClient } from '@pliuz/sdk'
const pliuz = new PliuzClient() // reads PLIUZ_API_KEY
const created = await pliuz.createApproval({
tool_name: 'refund',
tool_args: { amount: 100 },
idempotency_key: 'abc-123',
})
const fetched = await pliuz.getApproval(created.id)
await pliuz.reportExecution(created.id, {
status: 'success',
latency_ms: 42,
})Redaction
import { applyRedaction } from '@pliuz/sdk'
const clean = applyRedaction(
{ customer: { ssn: '123-45-6789', id: 'cus_x' } },
['customer.ssn'],
)
// { customer: { ssn: '<redacted>', id: 'cus_x' } }Supports dotted paths and [*] for array wildcards:
applyRedaction({ items: [{ card: '...' }] }, ['items[*].card'])Input is never mutated — returns a deep clone.
Vercel AI SDK integration (optional)
npm install aiimport { tool, generateText } from 'ai'
import { z } from 'zod'
import { gatedTool } from '@pliuz/sdk/adapters/ai'
const issueRefund = gatedTool(
{ toolName: 'issue_refund', policy: 'refund', redact: ['customer.ssn'] },
tool({
description: 'Issue a refund to a customer',
inputSchema: z.object({
customer_id: z.string(),
amount_cents: z.number().int().positive(),
}),
execute: async ({ customer_id, amount_cents }) =>
stripe.refunds.create({ customer: customer_id, amount: amount_cents }),
}),
)
const { text } = await generateText({
model: openai('gpt-4o'),
tools: { issueRefund },
prompt: 'Refund cus_123 for $50',
})The wrapped tool preserves description and the input schema (inputSchema in ai v5/v6, parameters in v4) — the LLM sees it identically. Only the execution path is gated.
Claude Agent SDK integration (optional)
npm install @anthropic-ai/claude-agent-sdkgatedTool() wraps tool(...) from the Claude Agent SDK, preserving name,
description, and inputSchema. toolName defaults to the tool's own name.
import { tool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk'
import { z } from 'zod'
import { gatedTool } from '@pliuz/sdk/adapters/claude-agent'
const issueRefund = gatedTool(
{ policy: 'refund', redact: ['customer_id'] },
tool(
'issue_refund',
'Issue a refund to a customer',
{ customer_id: z.string(), amount_cents: z.number().int().positive() },
async ({ customer_id, amount_cents }) => ({
content: [{ type: 'text', text: await stripe.refund(customer_id, amount_cents) }],
}),
),
)
const server = createSdkMcpServer({ name: 'billing', version: '1.0.0', tools: [issueRefund] })OpenAI Agents SDK integration (optional)
npm install @openai/agentsgatedTool() wraps tool({...}) from @openai/agents, preserving name,
description, and parameters. The SDK invokes tools with a JSON-string
payload and a live RunContext; the adapter parses the JSON so top-level
redact paths match, and the RunContext never enters the audit payload or the
idempotency key.
import { Agent, tool } from '@openai/agents'
import { z } from 'zod'
import { gatedTool } from '@pliuz/sdk/adapters/openai-agents'
const issueRefund = gatedTool(
{ policy: 'refund', redact: ['customer_id'] },
tool({
name: 'issue_refund',
description: 'Issue a refund to a customer',
parameters: z.object({ customer_id: z.string(), amount_cents: z.number().int() }),
execute: async ({ customer_id, amount_cents }) => stripe.refund(customer_id, amount_cents),
}),
)
const agent = new Agent({ name: 'Billing', instructions: '...', tools: [issueRefund] })
@openai/agentsalso ships a built-inneedsApprovalflag, but it only pauses the local run loop — no external routing, audit trail, or approver UI. Use this adapter when approval must leave the process and be recorded in Pliuz's hash-chained audit log.
Management API (ManagementClient)
gated() and PliuzClient use a per-agent key to request approvals. To manage Pliuz as code — policies, tools, agents, users — and to pull the signed audit trail for your SIEM, use ManagementClient. It authenticates with a machine token (plm_live_...) via Authorization: Bearer, read from PLIUZ_MANAGEMENT_KEY (separate from PLIUZ_API_KEY). Built for CI/CD, IaC (Terraform), and SIEM automation; scopes are enforced server-side, so a read-only token gets PliuzForbiddenError on writes.
import { ManagementClient } from '@pliuz/sdk'
const mgmt = new ManagementClient() // reads PLIUZ_MANAGEMENT_KEY
// Policy-as-code
const policy = await mgmt.createPolicy({
name: 'refunds-over-1000',
priority: 10,
conditions: { '>': [{ var: 'amount_cents' }, 100000] },
approver_group: 'finance',
sla_seconds: 3600,
enabled: true,
})
const { policies } = (await mgmt.listPolicies({ enabledOnly: true })) as {
policies: Array<{ name: string }>
}
// Audit trail — verify the hash chain and pull a signed export for your SIEM
const { verified } = (await mgmt.verifyChain()) as { verified: boolean }
const exported = await mgmt.exportEvents({ limit: 1000 })
// exported.jsonl is Ed25519-signed; exported.headers carry the x-pliuz-export-* proofsAll methods are async and typed:
| Surface | Methods |
|---|---|
| Policies | listPolicies, getPolicy, createPolicy, updatePolicy, disablePolicy |
| Tools | listTools, getTool, createTool, updateTool |
| Agents | listAgents, createAgent |
| Users | listUsers, createUser, archiveUser |
| Audit trail | listEvents, verifyChain, getChainHead, listAnchors, exportEvents |
The contract is published as management-openapi.yaml. All env vars can be passed as constructor options:
new ManagementClient({ token: 'plm_live_...', baseUrl: 'https://pliuz.mycompany.com' })Configuration
| Env var | Default | Purpose |
|---|---|---|
| PLIUZ_API_KEY | (required) | Per-agent API key. pli_live_... format. |
| PLIUZ_MANAGEMENT_KEY | (required for ManagementClient) | Machine token for the Management API. plm_live_... format. Separate from PLIUZ_API_KEY. |
| PLIUZ_BASE_URL | https://pliuz.com | Override for self-hosted or staging. |
All env vars can be passed as PliuzClient constructor options:
new PliuzClient({ apiKey: 'pli_live_...', baseUrl: 'https://pliuz.mycompany.com' })Production tips
Idempotency — read this before relying on it
gated() automatically generates a deterministic idempotency_key per call from (tool_name, args, sessionId). The key is hashed over the pre-redaction args (only the truncated SHA-256 leaves your process), so two calls that differ only in a redacted field get different keys — each needs its own approval.
Important: the backend dedupes on this key within a 24-hour window, scoped to the tenant and originating agent. An identical (tool_name, args, sessionId) combination replays the same approval during that window, which makes HTTP retries safe. Reusing the same idempotency_key within 24 hours with different tool_args is rejected as an idempotency conflict. After the window expires, the same key can create a new approval.
With the default sessionId: undefined, repeated identical calls from the same agent can still replay for up to 24 hours. If your agent legitimately repeats identical calls (crons, recurring jobs), set a per-run sessionId so each run gets its own approval:
const refund = gated(
{ policy: 'refund', sessionId: runId }, // scope approvals to this run
async (id: string, cents: number) => { /* ... */ },
)Within one run, retries are then safe: same args → one approval request, one human decision.
Cross-language idempotency
The hash algorithm matches the Python SDK for ASCII string/integer payloads. Known divergence: non-ASCII strings and float-typed numbers serialize differently between JSON.stringify and Python's json.dumps, producing different keys — don't rely on cross-language dedupe for those payloads yet.
When the approver edits the args
If a human decides with Edit & Approve, gated() executes the edited final_args, never the original call. With the default toolArgs mapper this is automatic. With a custom toolArgs mapper, provide applyFinalArgs to map the edited object back onto your function's arguments — otherwise the SDK throws PliuzEditNotApplicableError (it refuses to run args the human did not approve):
const refund = gated(
{
toolArgs: (id: string, cents: number) => ({ customer_id: id, amount_cents: cents }),
applyFinalArgs: (fa, [id]) => [id, fa.amount_cents as number] as const,
},
async (id: string, cents: number) => { /* ... */ },
)Decision latency (long-poll)
gated() waits for the human decision via server long-poll: each wait call
holds the connection open up to ~25s and returns the instant the approval is
decided. You get near-zero decision-delivery latency and ~12x fewer requests
than fixed-interval polling — no configuration needed. pollIntervalMs is only
the safety-floor backoff used if the server ignores the wait hint (it does NOT
shorten the long-poll window). The low-level client exposes it directly:
const a = await pliuz.getApproval(id, 25) // long-poll up to 25sCustom timeouts per call
- Live user-facing:
timeoutMs: 30_000 - Background jobs:
timeoutMs: 86_400_000(24 h) - Critical security gates: don't use
gated()— use a synchronous Pliuz dashboard flow
When gated() rejects mid-execution
If polling exceeds timeoutMs, you get PliuzApprovalTimeoutError. Your wrapped function never runs. The approval may still resolve later server-side — handle the retry with pliuz.getApproval(e.approvalId).
Edge / Bun / Deno
The HTTP layer uses native fetch + AbortController, but gated() imports node:crypto at module load — so today the SDK requires a Node-compatible runtime: Node ≥ 18.17, Bun, Deno (via npm:), and Cloudflare Workers with nodejs_compat. Vercel Edge runtime is NOT supported yet (no node:crypto there); use the Node.js runtime for routes that import this SDK. A Web-Crypto build is on the roadmap.
Versioning
This SDK follows SemVer. The current line is 0.x.y — minor versions may include breaking changes until 1.0.0.
| Surface | Stability |
|---|---|
| gated, PliuzClient | Stable across 0.x patches |
| errors.* | Stable across 0.x patches |
| adapters/* | Experimental — may shift before 1.0 |
Examples
See examples/ for runnable scripts:
basic.ts— barePliuzClient(no wrapper)gated-basic.ts—gated()happy pathvercel-ai-agent.ts— Vercel AI SDK agent with gated toolsclaude-agent.ts— Claude Agent SDK tool gated withgatedTool()openai-agents.ts— OpenAI Agents SDK tool gated withgatedTool()management-basic.ts—ManagementClientfor policy-as-code, agents, and the audit trail
Links
- Docs: https://pliuz.com/docs
- GitHub: https://github.com/pliuz/pliuz-ts
- Issues: https://github.com/pliuz/pliuz-ts/issues
- Python SDK:
pliuz
License
Apache 2.0. The Pliuz platform itself is proprietary — this SDK is the public client only.
