Build it. Ship it. Prove it.
Add Provedit to your AI agent in under ten minutes. Pick the path that matches the agent you ship, copy the snippet, and watch the action land in the operator console with policy, approval, and a signed chain entry already attached.
What you'll build #
By the end of this guide, every sensitive thing your agent does will:
- Be checked against your policy before it runs (allow / ask a human / block).
- Optionally pause until a teammate approves it in the operator console.
- Land in a signed, tamper-evident chain with the agent, the user, the params, and the result.
- Be independently verifiable by anyone with your tenant id and the open-source verifier.
You write three lines around the action you care about. We do the rest.
Pick your path #
Five ways to record actions. Same chain, same signatures, same dashboard. Pick whichever matches how your agent runs today; you can mix any of them later.
I write the agent code myself
Node / TypeScript service that calls OpenAI, Anthropic, or your own tools. Wrap each tool function with one call.
MCP proxyI use Cursor, Claude Code, or Copilot
Drop the proxy between the client and any MCP server. No code changes; every tools/call goes through the gate.
I want Provedit-native tools
Drop-in MCP server exposing gated shell, fs, http, git. Use it instead of wrapping a third-party server.
I orchestrate in n8n
Community node with Record Invocation, Record Result, and Check Policy operations. Same chain as the SDK; no code.
AGT via OTLPMy agent uses Microsoft AGT
Point your OpenTelemetry Collector at our OTLP endpoint. AGT keeps its in-process decision; Provedit re-decides and signs.
After you ingest Any tenant operator can audit a chain slice end-to-end with the @provedit/verifier CLI: continuity, signatures, anchors. That is read-only verification, not an ingest path.
Mental model: one gate, every entry #
Every integration above does the same three things in the same order. If you remember this picture, the rest of the docs is just syntax.
The same gate that enforces the policy also writes the evidence. There is no log you have to trust separately; the record and the decision are atomic.
Prerequisites #
- Node.js 20+ (the SDK and proxy use built-in
fetch,crypto, andasync_hooks). - A Provedit tenant and an agent key. Both are self-serve: register at app.provedit.ai/register to create the tenant, then mint a key on app.provedit.ai/account/agent-keys. Takes about a minute.
- For MCP: any MCP-capable client (Cursor, Claude Code, VS Code Copilot agent mode, OpenAI Assistants with MCP, your own).
PROVEDIT_AGENT_KEY.Bootstrap with your coding agent #
This is the fast path if you already work in Cursor, Claude Code, GitHub Copilot agent mode, Windsurf, or any other AI coding assistant. Open your repo in the assistant, paste the prompt below into the chat, and let it do the wiring. It is written to be safe (no destructive commands, asks before writing files outside the integration scope, refuses to invent the agent key) and leaves you with a runnable quickstart check you can verify in the console. Prefer to do it by hand? Skip to the 5-minute quick start below.
You are going to integrate the Provedit SDK into this repository so that every
high-consequence action our AI agent takes is gated by policy and recorded on a
signed, tamper-evident chain.
Authoritative reference: https://provedit.ai/docs.html
If anything below is ambiguous, fetch that page and prefer it over your priors.
## What Provedit is
A control + evidence layer for AI agents. You wrap a sensitive operation that an
AI model decides to invoke (a tool call, function call, MCP `tools/call`, or the
body of a handler that exists only because the model asked for it) in
`provedit.run(...)`. Provedit evaluates policy first; only on `allow` (or after a
human approves in the operator console) does your code execute. The action,
params, target, actor, policy decision, and approval are written atomically to
a hash-chained ledger.
## Scope: what counts as "an AI action" (read this carefully)
Provedit is for actions DECIDED BY AN AI MODEL. In this repo that means one of:
- A tool / function exposed to an LLM via OpenAI tools, Anthropic tools,
Vercel AI SDK, LangChain/LangGraph tools, an MCP server's `tools/call`,
a Cursor/Claude Code/Copilot custom tool, etc.
- The handler the model triggers when it picks one of those tools.
- An agent loop / orchestrator step that the model drives (planner picks
next action, executor runs it).
Provedit is NOT for:
- Regular REST/GraphQL/RPC endpoints called by a human-driven UI or by
another backend service. Those are app code, not agent actions.
- Cron jobs, queue workers, webhooks, or migrations that a human or a
deterministic system triggers.
- Internal helpers, DB queries, validators, formatters, business-logic
functions that happen to be "sensitive" but are not invoked by a model.
- SDKs/clients to third-party services unless the caller is a model.
If the same function is reachable BOTH from an AI tool call AND from a
human-driven endpoint, wrap it ONLY at the AI entry point (the tool handler),
not at the shared inner function. Otherwise you will double-log and gate human
traffic that does not belong in the agent ledger.
If this repository has no LLM/agent surface at all (no tool definitions, no
MCP server, no chat loop, no `openai`/`anthropic`/`@ai-sdk`/`langchain`/`mcp`
imports), STOP and tell me. Do not invent an agent to wrap.
## Goals for this task
1. Add `@provedit/sdk` to the project (Node 20+).
2. Create a single client module that other code imports.
3. Identify every AI-invoked tool / function call in this repo (see Scope
above). Concretely, look for:
- `tools: [...]` arrays passed to `openai.chat.completions.create`,
`anthropic.messages.create`, `generateText`/`streamText` from the
Vercel AI SDK, LangChain `tool(...)` / `StructuredTool`, etc.
- MCP server registrations (`server.tool(...)`,
`setRequestHandler('tools/call', ...)`).
- Custom agent loops that dispatch on a model-chosen action name.
For each tool, list: the tool name the model sees, the file/handler
that runs when the model picks it, and the real side effect (refund,
email send, deploy, file write, db write, shell exec, etc.). Group
them by risk class. Do NOT wrap anything until I confirm. Explicitly
skip and call out any sensitive code path that is NOT reachable from
an AI tool call so I can see you considered and excluded it.
4. Wrap each confirmed tool handler with `provedit.run({ tool, params,
target, sessionId? }, async () => /* original work */)`. The `tool`
string SHOULD match the name the model sees, so the audit timeline
reads the same as the model's tool-call log. Only add an `actor`
block with `userEmail` if the surrounding code already has an
authenticated end-user in scope (a logged-in session whose chat
triggered the agent, an authenticated request, an OAuth context).
If the agent runs headless (cron-driven, queue-driven, background
worker), omit `actor`; the agent (agentId) is the accountable
principal.
5. Add a quickstart check that submits one safe action and asserts the response
contains an `actionId` and a `decision`.
6. Update `.env.example` (never `.env`) and the README with the two required
env vars.
7. Tell me, in the final reply, that the SDK ships with `provedit.mcp.default`
already active on every tenant: it auto-allows `quickstart.*` tools (so the
step-5 quickstart check passes on day zero) and routes every other tool to
`require_approval` via a `*` catch-all. To actually gate something other
than the built-in tools, I must author a tenant-specific policy in
app.provedit.ai/policies and bind it to the agent key in
app.provedit.ai/account/agent-keys. Suggest a starter policy named after
the agentId (one rule per wrapped tool, mapping each tool to allow /
require_approval / deny). Do NOT pass `policyName` on `createClient` or
on individual `run()` calls: policy belongs in the portal so operators
can rebind it without a code deploy, and so the console UI never lies
about which policy actually decided an action.
## Required env vars
- `PROVEDIT_API_URL` default https://api.provedit.ai
- `PROVEDIT_AGENT_KEY` format `pvk_` + 43 base64url chars (47 chars total)
Ask me for the agent key value, or the name of the secret manager entry that
holds it. Do not invent a value, do not commit it, do not log it.
## Install
npm install @provedit/sdk
Node 20+. The import specifier is `@provedit/sdk`.
## Client module (create this file)
// src/provedit.ts
// NOTE: do NOT pass `policyName` here. The policy is bound to the agent
// key in app.provedit.ai/account/agent-keys, so ops can rebind without a
// code deploy and the console UI always reflects what actually decided.
import { createClient } from '@provedit/sdk';
export const provedit = createClient({
apiUrl: process.env.PROVEDIT_API_URL ?? 'https://api.provedit.ai',
agentKey: process.env.PROVEDIT_AGENT_KEY!,
agentId: '<short-id-for-this-service>', // e.g. 'crm-assistant'
vendor: '<openai|anthropic|cursor|copilot|custom>',
});
## Wrapping pattern
For each confirmed sensitive call site:
const { actionId, output, decision } = await provedit.run(
{
tool: 'issue_refund', // verb_noun, stable
params: { customerId, amountCents }, // recorded as-is, redact secrets
target: { kind: 'payment', ref: customerId,
summary: `refund $${(amountCents/100).toFixed(2)} to ${name}` },
// actor.userEmail is OPTIONAL and should ONLY be set when the
// surrounding code has an authenticated end-user in scope
// (a logged-in session, an authenticated request, an OAuth
// context). Most SDK integrations are system-to-system (CRM,
// billing, deploy job) and have no such user; in that case
// omit actor entirely and let the agent (agentId) be the
// accountable principal. If a user IS available, derive the
// email from the session and pass it through, and the action
// will be attributed to the agent AND the user. Approvals
// always route to human reviewers in the operator console.
// actor: { userEmail: session?.user?.email }, // only if a user is in scope
sessionId, // optional, groups a workflow
},
async () => /* the original sensitive call */
);
Rules:
- The arrow function MUST contain only the work that needs gating. Do all
validation, logging, and non-sensitive prep outside.
- `params` is written to the chain. Redact secrets, tokens, full PANs.
- `target.summary` is what a human reviewer reads first. Make it specific.
- If policy denies, `provedit.run` throws. Catch it where you would catch
any other authorisation error and return a clean 403/refusal to the user.
- If policy is `require_approval`, the call blocks until a human approves
in app.provedit.ai. Plan UX accordingly (status pages, retries, queues).
## Quickstart check (add this file)
// scripts/provedit-quickstart.js (plain JS; for TypeScript, use the .ts twin)
import { provedit } from '../src/provedit.js';
async function main() {
const r = await provedit.run(
{ tool: 'quickstart.ping', params: {}, target: { kind: 'other',
ref: 'quickstart', summary: 'sdk wiring quickstart check' } },
async () => ({ ok: true })
);
if (!r.actionId || !r.decision) throw new Error('SDK wiring failed');
console.log('OK', r.actionId, r.decision);
await provedit.close(); // drain undici keep-alive pool on Windows
}
main().catch(async e => { console.error(e); await provedit.close().catch(()=>{}); process.exit(1); });
For a JS repo, run it with `node --env-file=.env scripts/provedit-quickstart.js`.
For a TS repo, the equivalent is
`node --env-file=.env -r ts-node/register scripts/provedit-quickstart.ts`.
The quickstart check uses `tool: 'quickstart.ping'` deliberately: the default
policy (`provedit.mcp.default`) auto-allows everything under the `quickstart.*`
prefix so a brand-new tenant returns OK on the first call without a human in
the loop. Any tool outside that prefix routes to `require_approval` until you
author a real policy.
Note: `target.kind` is display-only metadata for the audit timeline (max 32
chars). Policy never matches on it. Use any short label that helps a human
reviewer: the SDK type autocompletes common values ('payment', 'url',
'file', 'db', 'repo', 'email', 'record', 'cloud', 'process', 'message',
'deploy', 'secret', 'iam', 'k8s', 'other') and accepts any string for
domain-specific kinds like 'flight_query' or 'kyc_doc'. One small caveat:
if you pass kind:'url', set ref to an actual http(s):// URL, otherwise the
verify view will mislabel the action and the SDK warns once per process.
## Hard rules
- ONLY wrap call sites that are invoked by an AI model (tool handlers,
function-call handlers, MCP `tools/call` targets, agent-loop dispatch).
Do NOT wrap regular REST endpoints, GraphQL resolvers, RPC handlers,
cron jobs, queue workers, migrations, or internal helpers that humans
or deterministic systems call. Those are not agent actions.
- If a function is shared between an AI tool and a human-driven endpoint,
wrap the AI tool handler, not the shared inner function.
- Do NOT add Provedit calls inside hot loops, request middleware, or any
code path that runs per-token.
- Do NOT swallow `provedit.run` errors. A throw means policy said no, or
the gate is unreachable; both are bugs the caller must see.
- Do NOT wrap read-only diagnostic calls unless I ask.
- Do NOT add a new logger, telemetry stack, or framework. The SDK is one
function: `run`. That is the entire integration surface.
- Do NOT modify CI, deployment, or auth code beyond adding the env var name.
- Do NOT commit `.env`, agent keys, or anything matching `pvk_*`.
## Deliverable
Reply with:
1. The list of AI tools / handlers you propose to wrap, grouped by risk
class, with one-line justifications. For each entry include the tool
name the model sees and the file/symbol of the handler. Also list, in
a separate "Considered but excluded" section, any sensitive code paths
you found that are NOT reachable from an AI tool call, so I can see
you ruled them out on purpose. WAIT for my confirmation.
2. After I confirm: a single PR-style diff (paths + hunks), the updated
README section, and the exact command to run the quickstart check.
3. Anything you were unsure about, as explicit questions, not guesses.
4. A reminder, at the end of the final reply, that `provedit.mcp.default`
is already seeded on every tenant: it auto-allows `quickstart.*` (so the
quickstart check passes immediately) and require-approves anything else via
a `*` catch-all. To actually gate the wrapped tools the right way,
author a tenant policy in app.provedit.ai/policies, with one rule per
wrapped tool (each mapped to allow / require_approval / deny based on
the risk class you put it in at step 1), then bind that policy to the
agent key in app.provedit.ai/account/agent-keys. Policy lives in the
portal, never in the agent code: that way ops can rebind without a
code deploy and the console always shows the policy that actually
decided each action.
Want this prompt as a file? Save the block above as PROVEDIT_AGENT_PROMPT.md in your repo root and reference it from your assistant's project rules / system prompt so future agent sessions stay consistent.
SDK: 5-minute quick start #
Five steps. Each one is a single command or a copy-paste block. If a step fails, jump to Troubleshooting.
-
Install the SDK
From npm (Node 20+):
npm install @provedit/sdkPackage page: npmjs.com/package/@provedit/sdk. Source: github.com/provedit/sdk-js. The import specifier is
@provedit/sdk. -
Set two environment variables
Add to your
.env(or your secret manager). Never commit the agent key.PROVEDIT_API_URL=https://api.provedit.ai PROVEDIT_AGENT_KEY=pvk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -
Create the client once at startup
// src/provedit.ts import { createClient } from '@provedit/sdk'; export const provedit = createClient({ apiUrl: process.env.PROVEDIT_API_URL!, agentKey: process.env.PROVEDIT_AGENT_KEY!, agentId: 'crm-assistant', // your service / agent id vendor: 'openai', // optional, for the actor block }); -
Wrap your first sensitive action
Pick one tool that does something you'd want to audit. Wrap the call site:
import { provedit } from './provedit'; const { actionId, output, decision } = await provedit.run( { tool: 'issue_refund', params: { customerId: 'c_123', amountCents: 4800 }, target: { kind: 'payment', ref: 'c_123', summary: 'refund $48.00 to ACME' }, // actor.userEmail is optional. The SDK is usually wired to a system // (CRM, billing, deploy job) where the agent identity is the // accountable principal. If the surrounding tool already has a // user in scope (a logged-in session, an authenticated request, // an OAuth context), pass that email through and the action will // be attributed to them as well as the agent. // actor: { userEmail: req.session?.user?.email }, }, async () => stripe.refunds.create({ charge: 'ch_xxx', amount: 4800 }) );The arrow function only runs if policy says
allow(or after a human approves). If policy denies,provedit.runthrows before your code executes. Approval requests still go to humans (the operator console assignees), even when the action itself has no end-user attached. -
Confirm it landed
Open
app.provedit.ai/agents. YouragentIdshows up in the list within a second or two, with a count of1and a "last seen" of just now. Click into it to see the action, actor, policy verdict, and signature. If you want the chronological feed across every agent instead, switch to/timeline. -
Author a policy (required to actually gate)
Until you create and activate a policy, the API returns a synthetic
allowfor every call. The chain still records intent and result, but nothing is enforced and no human approval is ever requested. This step is what turns Provedit from a recorder into a control point.- Open
app.provedit.ai/policiesand click New policy. - Name it after the agent you just wired (e.g.
crm.assistant.v1). Add one rule per tool you wrapped, in priority order. First match wins. Each rule maps a tool pattern to one ofallow,require_approval, ordeny. - Save and click Activate on the version.
- Open app.provedit.ai/account/agent-keys and bind the policy to the agent key your service uses. From this point on, every call made with that key is decided against that policy. Do not pass
policyNamein your agent code: keeping policy in the portal means ops can rebind without a code deploy, and the console always shows the policy that actually decided each action.
Re-run the smoke test. If your policy says
require_approvalfor that tool, the call now blocks until you click Approve in the operator console. If it saysdeny,provedit.runthrowsPolicyDeniedErrorbefore your code executes. That is the gate working. - Open
provedit.mcp.default) that requires approval on writes. It exists so MCP tools have a sane default; for SDK integrations, author your own policy named after the agent and bind it to the agent key in the console.Your first wrapped call, annotated #
Every field on provedit.run exists for a reason. Here is what each one does and why an auditor cares.
await provedit.run(
{
// What the agent is trying to do. Becomes the action type
// in the operator console (e.g. "issue_refund").
tool: 'issue_refund',
// The arguments. Recorded as-is on the chain.
// Avoid raw secrets; redact or hash before passing.
params: { customerId: 'c_123', amountCents: 4800 },
// What the action affects, in plain terms.
// 'kind' groups similar actions; 'ref' lets you search by id;
// 'summary' is what a human reviewer reads first.
target: {
kind: 'payment',
ref: 'c_123',
summary: 'refund $48.00 to ACME',
},
// actor.userEmail is optional. The agent (agentId) is the
// accountable principal for SDK integrations wired to systems
// (CRMs, deploy jobs, billing). If the surrounding tool already
// has a user in scope (a session, an authenticated request, an
// OAuth context), derive the email from there and pass it
// through. For interactive IDE / MCP use, prefer the
// PROVEDIT_USER env var on the proxy instead.
actor: { userEmail: 'alice@acme.com' },
// Optional: groups all calls in one workflow / chat
// under the same timeline node.
sessionId: 'sess_xyz',
},
// The actual work. Only runs after policy + approval pass.
async () => stripe.refunds.create({ charge: 'ch_xxx', amount: 4800 })
);
Verify it worked
You can check three places:
- Console. The action appears under Operations within a second.
- Return value.
actionIdis the chain id; deep-link withhttps://app.provedit.ai/timeline?q=<actionId>. - Verifier CLI. Re-prove the chain locally (see Verify the chain).
Recipe: OpenAI tool calls #
The most common case. Register each tool once, hand the resulting OpenAI specs to chat.completions.create, then dispatch model tool calls through Provedit.
import { wrapOpenAITools, toToolMessage } from '@provedit/sdk/openai';
import { provedit } from './provedit';
import OpenAI from 'openai';
const openai = new OpenAI();
const { toolMap, dispatchAll } = wrapOpenAITools(provedit, [
{
name: 'issue_refund',
targetKind: 'payment',
describe: (a) => ({
ref: a.customerId as string,
summary: `$${(a.amountCents as number) / 100} to ${a.customerName}`,
}),
handler: async (a) => stripe.refunds.create({
charge: a.chargeId as string,
amount: a.amountCents as number,
}),
},
// ...more tools
]);
const tools = Object.values(toolMap).map((t) => t.openaiSpec);
const completion = await openai.chat.completions.create({
model: 'gpt-4o', messages, tools,
});
const calls = completion.choices[0].message.tool_calls ?? [];
const results = await dispatchAll(calls, {
sessionId: conversationId,
reportErrors: true, // turn thrown errors into tool messages
});
messages.push(...results.map(toToolMessage));
// Before: model calls land directly on Stripe with no audit trail.
const completion = await openai.chat.completions.create({ model, messages, tools });
for (const call of completion.choices[0].message.tool_calls ?? []) {
if (call.function.name === 'issue_refund') {
const a = JSON.parse(call.function.arguments);
await stripe.refunds.create({ charge: a.chargeId, amount: a.amountCents });
// ... no policy check, no human approval, no signed record.
}
}
Each tool the model calls is now recorded twice (intent + result), gated by your active policy, and visible in the console under one session id.
Recipe: Anthropic tool use #
No special wrapper required; just call provedit.run inside your tool dispatcher.
import Anthropic from '@anthropic-ai/sdk';
import { provedit } from './provedit';
const anthropic = new Anthropic();
const resp = await anthropic.messages.create({
model: 'claude-3-5-sonnet-latest',
max_tokens: 1024,
tools,
messages,
});
for (const block of resp.content) {
if (block.type !== 'tool_use') continue;
if (block.name === 'issue_refund') {
const a = block.input as any;
await provedit.run(
{
tool: 'issue_refund',
params: a,
target: { kind: 'payment', ref: a.customerId, summary: `$${a.amountCents/100}` },
sessionId: conversationId,
vendor: 'anthropic',
modelName: 'claude-3-5-sonnet-latest',
},
async () => stripe.refunds.create({ charge: a.chargeId, amount: a.amountCents })
);
}
}
Recipe: Express endpoint with approval #
An HTTP endpoint that triggers a sensitive action, waits for human approval, and surfaces the result to the caller.
import express from 'express';
import { provedit } from './provedit';
import {
PolicyDeniedError, ApprovalRejectedError, ApprovalTimeoutError,
} from '@provedit/sdk';
const app = express();
app.use(express.json());
app.post('/refunds', async (req, res) => {
const { customerId, amountCents } = req.body;
try {
const out = await provedit.run(
{
tool: 'issue_refund',
params: { customerId, amountCents },
target: { kind: 'payment', ref: customerId, summary: `$${amountCents/100}` },
actor: { userEmail: req.user.email },
},
async () => stripe.refunds.create({ charge: req.body.chargeId, amount: amountCents })
);
res.json({ ok: true, actionId: out.actionId });
} catch (e) {
if (e instanceof PolicyDeniedError) return res.status(403).json({ error: 'policy_denied', actionId: e.actionId });
if (e instanceof ApprovalRejectedError) return res.status(403).json({ error: 'approval_denied', actionId: e.actionId });
if (e instanceof ApprovalTimeoutError) return res.status(408).json({ error: 'approval_timeout', actionId: e.actionId });
throw e;
}
});
Recipe: n8n workflow #
For no-code and low-code teams, the
n8n-nodes-provedit community node
(npm,
source)
adds Provedit recording, policy gating, and human approvals
to any n8n workflow. Install once from Settings -> Community Nodes,
paste an agent key into the Provedit API credential, and you have
four operations to compose with:
- Record Invocation - writes the chain entry and returns the server-stamped
policyDecision(allow/require_approval/deny). - Record Result - audit follow-up linked by
parentActionId; carries duration and a hashed output digest. - Record Event - standalone audit log ("user said X", "webhook fired").
- Check Policy - dry-run a decision without writing to the chain.
The verdict is bound to the invocation row itself. A denial is the same record, not a second "blocked" entry to reconcile, so the console shows one auditable trail per request.
Refund flow with human approval
A typical workflow: Webhook -> Record Invocation ->
Switch on policyDecision, with three branches:
allow-> issue the refund -> respond200.require_approval-> long-poll the wait endpoint -> branch onapprovalStatus.deny-> respond403.
n8n has no native "wait for external decision" node, so the
require_approval branch uses a plain HTTP Request node
against the long-poll endpoint, authenticated with the Provedit API
credential:
GET https://api.provedit.ai/v1/actions/{{ $('Record Invocation').item.json.id }}/wait?timeoutMs=120000
The endpoint blocks for up to two minutes and returns
{ approvalStatus: 'approved' | 'denied' } the moment an
operator clicks Approve or Deny in the Provedit console. A subsequent
IF node routes to either the side effect or a denial response.
For approvals that may take longer than two minutes, loop the HTTP node
back to itself with a short Wait node between iterations.
A complete, runnable refund-assistant workflow JSON ships in the SDK handoff bundle. Drop us a note and we'll send the import file plus a walkthrough video.
Recipe: production deploy #
Wrap your CI deploy step so every kubectl apply from an agent is gated and signed.
await provedit.run(
{
tool: 'deploy.kubernetes',
params: { manifest: 'prod/api.yaml', cluster: 'prod-eu' },
target: { kind: 'deploy', ref: 'prod/api.yaml', summary: 'apply prod/api.yaml to prod-eu' },
sessionId: process.env.GITHUB_RUN_ID,
actor: { userEmail: process.env.GITHUB_ACTOR_EMAIL },
},
async () => execa('kubectl', ['apply', '-f', 'prod/api.yaml'])
);
Building an agent with @provedit/sdk #
A practical guide based on shipping five real agents (flight-tracker, refund assistant, deploy bot, analytics assistant, secrets vault) against SDK 0.3.1. Read this before you ship. It covers the things you cannot learn from the API reference alone.
1. The minimum viable agent
import { createClient } from '@provedit/sdk';
import { wrapOpenAITools } from '@provedit/sdk/openai';
import OpenAI from 'openai';
const openai = new OpenAI();
// Policy is bound to this agent's key in app.provedit.ai/account/agent-keys.
// Do NOT pass policyName here; keeping it in the portal lets ops rebind
// without a code deploy and keeps the console honest about what decided.
const provedit = createClient({
agentKey: process.env.PROVEDIT_AGENT_KEY,
agentId: 'refund-agent',
vendor: 'openai',
modelName: 'gpt-4o-mini',
});
const openaiTools = [
{
type: 'function',
function: {
name: 'process_refund',
description: 'Refund a charge.',
parameters: {
type: 'object',
properties: {
chargeId: { type: 'string' },
amountCents: { type: 'integer' },
reason: { type: 'string' },
},
required: ['chargeId', 'amountCents', 'reason'],
},
},
},
];
const { dispatchAll } = wrapOpenAITools(provedit, [
{
name: 'process_refund',
targetKind: 'payment',
describe: (a) => ({
ref: a.chargeId,
summary: `Refund ${(a.amountCents/100).toFixed(2)} on ${a.chargeId}`,
}),
handler: async (a) => doRefund(a),
},
]);
Note the two parallel arrays. OpenAI needs the function
schemas (openaiTools); Provedit needs the handlers,
describe, and targetKind. They are linked by
name only, so spell it the same in both places.
2. System-prompt rules (the part that breaks audit trails)
Never tell the model what the policy will allow or deny.
This is the single biggest mistake. If your prompt says "refunds over $1000 are denied by policy", the LLM helpfully refuses client-side, the SDK never sees the call, and the timeline shows nothing. The gate is bypassed by your own prompt.
Bad:
- read_secret on prod/* is denied by policy. Tell the user to use break-glass.
- Refunds larger than $10k will be denied; do not bother trying.
- update_record requires approval; ask the user before calling it.
Good:
- ALWAYS call the requested tool. Do NOT pre-filter or refuse client-side
based on what you think is allowed. The Provedit policy decides allow /
approval / deny and you must report what actually happened.
- If a tool returns a "blocked" object with a "reason" of "policy_denied",
"approval_rejected", or "approval_timeout", explain the reason to the user
plainly and stop.
- Pass arguments through verbatim from the user; do not clamp or normalize.
That five-line block is the audit-trail-preserving system prompt. Drop it into every agent.
3. Tool naming
wrapOpenAITools registers each tool under name.
OpenAI requires ^[a-zA-Z0-9_-]{1,64}$ for function names, so:
process_refundworks.flight-locateworks.flight.locatebreaks (OpenAI rejects the dot).
Policy match.tool does support dots (and supports *
glob over non-slash runs), but you must use a name that round-trips
through OpenAI. Standardize on snake_case verb_noun.
4. Policies in JSON
{
"policyName": "refund.assistant.v1",
"rules": [
{ "match": { "tool": "smoke.ping" }, "decision": "allow" },
{ "match": { "tool": "lookup_customer" }, "decision": "allow" },
{ "match": { "tool": "process_refund", "argsContains": "\"amountCents\":1000000" }, "decision": "deny" },
{ "match": { "tool": "process_refund" }, "decision": "require_approval" },
{ "match": { "tool": "*" }, "decision": "deny" }
]
}
- First match wins.
argsContainsis a substring ofJSON.stringify(params). Use it to gate on values like"cluster":"prod,"name":"prod/, or amount thresholds.- Always end with
{ "tool": "*", "decision": "deny" }as a backstop unless you really want unmatched tools to allow. - Do NOT put a
targetKindfield on rules. The server only matches on tool + args.
5. Bootstrapping a tenant from CI
The SDK does not include a CLI; use the REST API. All mutations require
an owner/admin user JWT, not a pvk_ agent
key.
POST /v1/policies { policyName, rules[...] }
POST /v1/policies/:name/activate { version }
GET /v1/tenants/:t/agent-keys
POST /v1/tenants/:t/agent-keys { label, policyName? }
-> { keyId, secret, prefix, ... }
PATCH /v1/tenants/:t/agent-keys/:keyId { policyName } (rebind)
DELETE /v1/tenants/:t/agent-keys/:keyId
Headers on every request: Authorization: Bearer <user JWT>,
x-provedit-tenant: <tenantId>,
Content-Type: application/json.
Idempotent recipe (publish-or-update):
const created = await api('POST', '/v1/policies', body);
await api('POST', `/v1/policies/${body.policyName}/activate`, { version: created.version });
POST always creates a new immutable version, so re-running is safe.
For agent keys, list first and PATCH-rebind if the key already exists,
since free-plan tenants are capped at 5 agent keys total
(Pro = 25). Re-minting fails with 402 plan_limit_reached.
const keys = await api('GET', `/v1/tenants/${tenantId}/agent-keys`);
const existing = keys.find(k => k.prefix === storedSecret.slice(0, 12));
if (existing && existing.policyName !== desired) {
await api('PATCH', `/v1/tenants/${tenantId}/agent-keys/${existing.keyId}`, { policyName: desired });
}
6. Approvals and the long-poll caveat
approvalTimeoutMs defaults to 12h in 0.3.0. Internally the
SDK calls GET /v1/actions/:id/wait?timeoutMs=... as a single
long-poll request.
Production gotcha: any HTTP gateway between you and the
API (cloud LB, reverse proxy, app gateway) typically caps individual idle
connections at 60-240s. If you set 12h but your gateway closes the socket
at 4 minutes, the SDK will throw early. Until a release with
auto-reconnect lands, set approvalTimeoutMs to something
just under your gateway idle limit, or run without a gateway in front.
7. Process lifecycle
provedit.close() tears down undici's
process-global dispatcher. Calling it after one client
breaks every subsequent client in the same process with a bare
fetch failed. Rules:
- One client per process: call
close()at exit. - Multiple clients per process: call
close()exactly once, after the last client is done. - On Windows, calling
close()before exit prevents anAssertion failed: !(handle->flags & UV_HANDLE_CLOSING)from undici's idle sockets. On Linux/macOS it is optional.
8. Reporting blocked actions back to the user
wrapOpenAITools catches PolicyDeniedError,
ApprovalRejectedError, and ApprovalTimeoutError
and returns:
{
"ok": false,
"actionId": "act_...",
"blocked": { "reason": "policy_denied", "message": "..." }
}
That object becomes the tool message back to the model.
Combined with the "report what happened" rule from section 2, the model
will translate this into a plain-English explanation for the user and the
action will appear in the Provedit timeline with the right verdict. No
bypass, full evidence.
9. Targets
target is display-only metadata for the audit timeline; the
policy engine never matches on it. target.kind is capped at
32 characters and the SDK type autocompletes a short list of common
values (file, process, db,
cloud, repo, record,
payment, email, message,
url, deploy, secret,
iam, k8s, other) while still
accepting any string so you can use domain-specific kinds like
flight_query or kyc_doc.
target.ref and target.summary are free text and
show up in the operator console; make them human-readable. One small
caveat: if you pass kind:'url', set ref to an
actual http(s):// URL or the verify view mislabels the
action and the SDK warns once per process.
10. Worked example: a complete agent file
import { wrapOpenAITools } from '@provedit/sdk/openai';
import { createClient } from '@provedit/sdk';
import OpenAI from 'openai';
const openai = new OpenAI();
// Policy is bound to the agent key in the Provedit portal. No policyName here.
const provedit = createClient({
agentKey: process.env.PROVEDIT_AGENT_KEY,
agentId: 'refund-agent',
vendor: 'openai',
modelName: 'gpt-4o-mini',
});
const SYSTEM_PROMPT = `You are a refund assistant.
Tools:
- lookup_customer(customerId): read.
- process_refund(chargeId, amountCents, reason): refund some or all of a charge.
Rules:
- ALWAYS call the requested tool. Do NOT pre-filter or refuse client-side.
The Provedit policy decides allow / approval / deny; you report the outcome.
- Pass arguments through verbatim from the user; do not clamp or normalize.
- If a tool returns a "blocked" object, explain the reason
(policy_denied / approval_rejected / approval_timeout) plainly and stop.
- No emojis. Be concise.`;
const openaiTools = [/* ... function defs as in section 1 ... */];
const { dispatchAll } = wrapOpenAITools(provedit, [/* ... handlers ... */]);
async function chat(userMessages) {
const messages = [{ role: 'system', content: SYSTEM_PROMPT }, ...userMessages];
for (let i = 0; i < 3; i++) {
const r = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages,
tools: openaiTools,
tool_choice: 'auto',
});
const msg = r.choices[0].message;
messages.push(msg);
if (!msg.tool_calls?.length) return msg.content;
const { toToolMessage } = await import('@provedit/sdk/openai');
const results = await dispatchAll(msg.tool_calls);
messages.push(...results.map(toToolMessage));
}
return 'I could not finish that workflow.';
}
process.on('beforeExit', () => provedit.close());
That is a complete, audit-trail-preserving agent. Replicate this skeleton, swap in your tools and policy name, and the operator console will show every attempt (allowed, approved, denied, or timed out) with the right verdict and no client-side bypass.
MCP integration: how the proxy works #
The Provedit MCP proxy is a transparent JSON-RPC proxy. It sits between
an MCP client (Cursor, Claude Code, Copilot agent mode, OpenAI Assistants
with MCP, your own) and any upstream MCP server. For every tools/call:
- Asks the Provedit API for a policy decision.
- Submits the action as an evidence entry, locked to the policy bundle hash that decided it.
- Blocks on operator approval when policy says so.
- Forwards to the upstream server (or short-circuits on deny / failure: fail-closed).
- Returns the upstream response back to the agent unchanged.
Everything that is not tools/call (initialize, ping, notifications, list operations, resources, prompts) is proxied through transparently with no inspection or recording.
Cursor / Claude Code #
Replace the upstream command / args in your client's MCP config with the proxy, and pass the upstream as arguments after --.
// .mcp.json (Cursor / Claude Code)
{
"mcpServers": {
"everything": {
"command": "node",
"args": [
"/abs/path/services/mcp-proxy/dist/index.js",
"--",
"npx", "-y", "@modelcontextprotocol/server-everything"
],
"env": {
"PROVEDIT_API_BASE": "https://api.provedit.ai",
"PROVEDIT_TENANT_ID": "ten_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"PROVEDIT_VENDOR": "cursor",
"PROVEDIT_AGENT": "cursor-agent-7f3a",
"PROVEDIT_USER": "alice@acme.com",
"PROVEDIT_MODEL": "claude-3.7-sonnet"
}
}
}
}
The proxy spawns the upstream as a child process and pipes stdio. Add --policy-name provedit.mcp.default (or any tenant policy) before -- to pin a specific policy.
VS Code / Copilot agent mode #
Same idea, in .vscode/mcp.json:
{
"servers": {
"provedit-mcp": {
"command": "node",
"args": [
"/abs/path/services/provedit-mcp/dist/index.js"
],
"env": {
"PROVEDIT_API_BASE": "https://api.provedit.ai",
"PROVEDIT_API_KEY": "pvk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"PROVEDIT_VENDOR": "copilot",
"PROVEDIT_AGENT": "copilot-agent",
"PROVEDIT_USER": "alice@acme.com"
}
}
}
}
provedit-mcp talks to the Provedit API directly (no upstream MCP server required), so you can drop it into any MCP-capable client and it brings its own gated toolbelt. If you want to gate a different upstream server instead, use the mcp-proxy recipe above.
provedit-mcp server #
A self-contained MCP server (currently v0.2.1) that exposes a registry of high-risk capabilities, each one policy-gated, optionally human-approved, and written to the chain. Designed to be the only way an MCP-capable agent touches the host: every tool below routes through POST /v1/policies/decide before the body runs, and an evidence entry plus a <tool>.result entry are recorded for every call.
Install from the blob (until npm publish):
curl -L -o provedit-mcp.tgz https://stproveditpltprod.blob.core.windows.net/sdk/provedit-mcp-latest.tgz
npm install -g ./provedit-mcp.tgz
provedit-mcp # binary on PATH, or:
node /abs/path/node_modules/@provedit/mcp/dist/index.js
| Tool | Args | Records |
|---|---|---|
shell_exec | (command, cwd?, shell?, timeoutMs?) | exit code, stdout/stderr SHA-256 + bytes, duration |
http_fetch | (url, method?, headers?, body?, timeoutMs?) | host (allowlist-evaluated against PROVEDIT_HTTP_ALLOWLIST), method, body hash, response status, response hash, duration. Header values are never recorded by name. |
fs_read | (path, encoding?, maxBytes?) | preImage hash + bytes, secret-detector scan on contents |
fs_write | (path, contents, encoding?, createDirs?) | preImage hash, postImage hash, bytes, secret scan on contents |
fs_delete | (path, recursive?) | preImage hash, recursive flag, dir vs file |
git | (subcommand, args?, cwd?, timeoutMs?) | subcommand allowlist (status, diff, log, show, blame, branch, tag, remote, fetch, pull, add, commit, push, checkout, switch, restore, rev-parse, config, ls-files, reset, stash); argv, cwd, exit code, stdout/stderr hashes, dangerous flag (push, reset --hard, branch -D, checkout --force, clean -f, stash drop) which the policy can match on to escalate to approval |
code_search | (pattern, flags?, cwd?, maxHits?, pathIncludes?) | regex source, files scanned, hit count, hits SHA-256. Walks the workspace, skips node_modules/.git/build artefacts, max 5000 files / 1 MB per file / 500 hits. |
code_edit | (path, edits[{find, replaceWith, expectedCount?}]) | preImage hash, postImage hash, hunk count, per-hunk match counts. Atomic: if any hunk's match count differs from expectedCount (default 1), zero bytes are written and the call errors. |
env_inspect | (match?, reveal?, names?) | name list with value lengths and a sensitivity heuristic (TOKEN/SECRET/KEY/PASSWORD/PWD/CREDENTIAL/PRIVATE); if reveal: true the plaintext values are returned and run through the secret detector (the chain entry records that reveal was requested). |
process_list | (filter?, max?) | row count, total before filter, full stdout SHA-256. Windows: tasklist /fo csv /nh. Else: ps -eo pid,ppid,comm,args. Max 500 rows, 10 s timeout. |
All tools share the same lifecycle: secret-scan args → (optional) pre-hook → policy decide (secret hits auto-escalate allow to require_approval) → submit pending action → await approval → execute → submit <tool>.result with parentActionId, postImage, outputDigest, sideEffects → post-hook. On block, the server returns MCP error -32003 with { actionId, reason }.
Hooks, lockdown, and prompts #
Lifecycle hooks
Two env vars, each a shell command, give you a side-channel break-glass and a SIEM/Slack notification path independent of the central policy:
PROVEDIT_MCP_PRE_HOOKruns after policy allow/approval and before the tool body executes. Non-zero exit aborts the call with MCP error-32004.PROVEDIT_MCP_POST_HOOKruns after the tool body, regardless of outcome. Non-zero exit is logged but does not change the response.
Both receive a JSON event on stdin and these env vars: PROVEDIT_HOOK_PHASE, PROVEDIT_HOOK_TOOL, PROVEDIT_HOOK_ACTION_ID. Hard 10 s timeout, stdout captured up to 1 KB.
// stdin payload
{ "phase": "pre", "tool": "shell_exec",
"args": { "command": "rm -rf /tmp/x" },
"actionId": "act_..." }
Locking down host built-in tools
The server's initialize response sets the MCP instructions field with an explicit list telling the model to use the MCP tools above instead of host built-ins (VS Code's run_in_terminal, read_file, edit_file, fetch_webpage, Cursor's shell built-ins, etc.). Set PROVEDIT_MCP_LOCKDOWN=true to emit the harder variant: "any use of a host built-in with an MCP equivalent is a rule violation."
Soft enforcement only: the host has to honour the system prompt. For hard enforcement, combine with:
- Disabling host built-ins in client settings (VS Code
chat.tools, Cursor project rules, Claude Codeclaude config). - Running the agent in a sandbox (Windows Sandbox, devcontainer, network namespace) where the only writable paths and the only network egress are reached through this server's tools.
- Issuing cloud credentials only as short-lived tokens minted per-action and scoped to the recorded target.
Prompts capability
The server advertises prompts/list and prompts/get so hosts can surface canonical system messages as one-click inserts:
| Prompt | Purpose |
|---|---|
provedit.system-rules | The canonical system prompt that tells the model to route everything through MCP tools and respect policy decisions. Inject once at session start. |
provedit.bootstrap-sdk | Multi-step plan for integrating @provedit/sdk into the current repo using only MCP tools. |
provedit.explain-block | Template for explaining a ToolBlocked error plainly to the end user. Args: reason, actionId?. |
Environment reference
PROVEDIT_API_BASE # required
PROVEDIT_API_KEY # pvk_... agent key (recommended)
PROVEDIT_TOKEN # JWT alternative
PROVEDIT_TENANT_ID # if using token
PROVEDIT_VENDOR # e.g. vscode, cursor, claude-code
PROVEDIT_AGENT # e.g. copilot-agent
PROVEDIT_USER # operator email
PROVEDIT_MODEL # e.g. claude-opus-4.7
PROVEDIT_APPROVAL_TIMEOUT_MS # default 120000
PROVEDIT_POLICY_NAME # optional; defaults to the policy bound to the key
PROVEDIT_HTTP_ALLOWLIST # comma-separated hosts; suffix-match
PROVEDIT_MCP_LOCKDOWN # "true" to harden lockdown instructions
PROVEDIT_MCP_PRE_HOOK # shell command, runs before each tool body
PROVEDIT_MCP_POST_HOOK # shell command, runs after each tool body
Policies & approvals #
Policies live server-side, per tenant. There is no
client-side requireApproval override in the SDK; to change
behaviour for a tool, edit the tenant policy in the operator console
(or via API). Edits take effect on the next call, no restart needed.
One bundle ships by default:
| Policy | What it does | Use when |
|---|---|---|
provedit.mcp.default | Day-zero default. Auto-allows smoke.* (so the SDK smoke test passes on a fresh tenant) and the legacy provedit-mcp tool names (fs.read/fs.write allow, fs.delete require_approval, shell.exec require_approval with rm -rf denied, http.fetch allow). Every other tool (including the current underscore-named MCP tools like shell_exec, git, code_edit, env_inspect) falls into a * catch-all that pauses for human approval, so all of the new capability surface is approval-gated until you author a tenant policy. | Day-zero default until you author your own. |
Anything beyond those tool names needs a tenant-authored policy. Create one via POST /v1/policies, activate via POST /v1/policies/:name/activate. Each chain entry locks in the active policy bundle hash at decision time, so audits remain reproducible across policy edits.
Pick which policy each agent uses
Bind the policy to the agent key in the portal. That is the entire workflow.
- Open app.provedit.ai/account/agent-keys.
- On the row for the agent's key, pick the policy from the dropdown.
- The next call from that key is decided against that policy. No deploy, no restart.
Policy is an operations concern: it changes when the risk picture changes, when a tool is added, when an audit finding lands. Keeping it in the portal means the people who own those concerns can act without a code change, and the console UI always shows the policy that actually decided each action. Putting policyName in agent code defeats both of those properties; the SDK accepts it for tests and one-off scenarios scripts only.
The resolved policy name, version, and bundle hash land on every action and are signed into the chain, so "which policy did this agent use?" is always answerable from the timeline.
Approval flow
- SDK or proxy receives
require_approvalfrom the API. - An approval request appears in the operator console (and via webhook, if configured).
- A reviewer clicks Approve or Reject.
- The approval signature is bound to the action and the executor either runs or throws.
Tune the wait time per client with approvalTimeoutMs (default 12 hours, matching the long-poll budget on the API). After the timeout, the SDK throws ApprovalTimeoutError with the actionId attached. If a load balancer or reverse proxy sits between you and the API, see section 6 of the agent guide for the gateway-idle caveat.
Sessions & tracing #
Use the same sessionId across one user interaction (chat thread, workflow run) so the timeline groups related calls. Nested provedit.run(...) calls inside another provedit.run(...) automatically inherit the session id and link via parentActionId through AsyncLocalStorage, so a multi-step trace forms a tree under one root.
const sessionId = 'sess_xyz';
await provedit.run({ ...stepOneOpts, sessionId }, stepOne);
await provedit.run({ ...stepTwoOpts, sessionId }, stepTwo); // same session
Policy API #
The console writes through these endpoints. You can also call them directly to script policy bootstrap as part of your CI.
Create a policy version
POST /v1/policies. Each call creates a new immutable version. Versions are auto-numbered (1, 2, 3, ...). Body:
{
"policyName": "crm.assistant.v1", // [a-z0-9][a-z0-9._-]{0,79}
"rules": [ // 1..200
{ "match": { "tool": "crm.lookup_*" }, "decision": "allow" },
{ "match": { "tool": "crm.send_email" }, "decision": "allow" },
{ "match": { "tool": "crm.update_customer" }, "decision": "require_approval" },
{ "match": { "tool": "crm.issue_refund" }, "decision": "require_approval" },
{ "match": { "tool": "shell.exec", "argsContains": "rm" }, "decision": "deny" },
{ "match": { "tool": "*" }, "decision": "require_approval" }
]
}
match.tool: glob with*matching any run of non-slash characters. Required.match.argsContains: optional substring search againstJSON.stringify(params). Use to denyshell.execwhen the args containrm -rf, etc.decision: one ofallow,require_approval,deny. Required.- First match wins. If no rule matches, the engine defaults to
allow; pin a final{ tool: '*' }rule to override that. - Returns the new
PolicyVersiondocument includingversion(the auto-incremented integer) andpolicyBundleHash. - Requires
operatorrole or higher on the user token. Agent keys (pvk_) are rejected with 403forbidden: the same key your SDK uses to record actions cannot also rewrite the policies that gate them. Bootstrap policies with a human operator session, or with an admin-issued one-shot token in CI.
Activate a version
POST /v1/policies/:name/activate. Points the active pointer at a specific version number. Body:
{ "version": 3 }
Returns the updated PolicyActivePointer. Same role rules: operator+, agent keys rejected.
Read
GET /v1/policies: list policies for the tenant with their active version and source (builtin,user,mixed).GET /v1/policies/:name: the currently active version document.404if no active pointer.GET /v1/policies/:name/versions: all versions newest first.GET /v1/policies/_builtins: the catalog of policies we ship (currently justprovedit.mcp.default).
Decide
POST /v1/policies/decide. The SDK calls this for you on every run(). Body:
{
"tool": "crm.issue_refund",
"params": { "customerId": "c_123", "amountCents": 4800 }
}
The policy is resolved server-side from the binding on your agent key (see Pick which policy each agent uses). Returns { decision, matchedRule, policyName, policyVersion, policyBundleHash }. Fail-closed semantics: if the agent key is bound to a policy with no active version, the API returns 404 { error: "policy_not_found", policyName, hint } and the SDK throws PolicyNotFoundError. There is no silent fallback to the tenant default.
Disable / delete
DELETE /v1/policies/:name/active: removes the active pointer (operator+). Versions are kept so you can re-activate.DELETE /v1/policies/:name: permanently delete a user-authored policy and all its versions (admin+). Built-ins return409 builtin_cannot_delete.
Roles & permissions #
Every membership in a tenant carries exactly one role. Roles are fixed (you cannot define new ones) and form a strict hierarchy: each role inherits everything the role below it can do.
viewer < auditor < operator < admin < owner
| Role | Can do | Typical user |
|---|---|---|
viewer |
Read actions, policies, agents, audit chain. No writes, no approvals. | Engineering manager, exec, read-only stakeholder. |
auditor |
Everything viewer can do, plus export the signed chain and run external verification. |
Internal audit, GRC, external assessor. |
operator |
Everything auditor can do, plus submit actions, approve or reject pending actions, edit and activate policies. |
Day-to-day reviewer: platform engineer on rotation, SRE, AppSec analyst. |
admin |
Everything operator can do, plus invite users, change member roles (up to admin), manage agent API keys, anchor the chain, manage webhooks and tenant settings. |
Tenant administrator, platform lead. |
owner |
Everything admin can do, plus promote or demote other owners and remove owners. At least one owner is always required. |
Account owner, head of platform, primary security sponsor. |
Quick capability matrix
| Capability | viewer | auditor | operator | admin | owner |
|---|---|---|---|---|---|
| Read actions, policies, audit chain | ✓ | ✓ | ✓ | ✓ | ✓ |
| Export & verify signed chain | ✓ | ✓ | ✓ | ✓ | |
| Submit actions, approve / reject | ✓ | ✓ | ✓ | ||
| Edit & activate policies | ✓ | ✓ | ✓ | ||
| Anchor chain, manage agent keys | ✓ | ✓ | |||
| Invite users, change roles up to admin | ✓ | ✓ | |||
| Promote / demote / remove owners | ✓ |
Notes on assignment
- The user who creates a tenant is automatically
owner. New tenants always start with exactly one owner. - Invites can grant
admin,operator,auditor, orviewer. To make someone anowner, an existing owner must promote them after they accept. - Default role on accepted invites and approved join requests is
operatorunless the inviter chose otherwise. - Agent API keys (
pvk_) act on behalf of a user membership and inherit that membership's role. Issue keys against a service-account user pinned to the lowest role that works. - Legacy memberships from earlier builds (role
member) are read asoperator. No migration step is required.
Least-privilege guide #
Pick the lowest role that lets the person (or service) do their job. The defaults below are a safe starting point for most tenants.
Recommended starting allocation
| Persona | Role | Why |
|---|---|---|
| Account owner / platform lead | owner | Needs to manage other owners and survive personnel changes. Keep at least two. |
| Tenant admins (rotated) | admin | Manage members, agent keys, anchors. No need for owner unless they manage other owners. |
| On-call reviewer / approver | operator | Approves and rejects pending actions. Cannot invite users or rotate keys. |
| Internal audit / GRC | auditor | Can export and verify the chain without touching policies or approvals. |
| Engineering manager / exec viewer | viewer | Read-only access to dashboards. No export, no approval rights. |
| External assessor (time-boxed) | auditor | Grant via invite, then remove the membership when the engagement ends. |
| CI / agent service account | operator | Lowest role that can submit actions and decide on its own approvals if policy permits. |
Operating rules of thumb
- Two owners minimum. One owner is a single point of failure; the API blocks removing or demoting the last one. Promote a second person before vacation or handover.
- Owner is rare. Most platform admins should be
admin, notowner. Reserveownerfor people who must change other owners. - Default invites to
operatoror lower. Only escalate toadminwhen the person needs to manage members or agent keys. - Separate read from approve. Stakeholders who only watch dashboards belong in
viewer; auditors who need to verify the chain belong inauditor. Neither needsoperator. - One agent key per service account. Issue dedicated keys against a service-account user (not a real human) and pin that user to
operator. Rotate keys when the agent is retired. - Audit role changes regularly. Membership changes are recorded in the audit chain. Review the members list after every quarter or after staff turnover and downgrade anyone who no longer needs the role.
- Off-board promptly. Remove the membership rather than downgrading to
viewerwhen someone leaves the team. Owners can only be removed by another owner.
Common mistakes
- Everyone is admin. Pull most people back to
operatororviewer; a tenant with five admins and no real separation of duty fails most audit controls. - Auditors with operator rights. An auditor who can also approve actions cannot then attest independently. Keep audit personas at
auditor. - Service accounts as admin. If a CI bot only submits actions,
operatoris enough.adminfor a bot widens the blast radius of a leaked key. - Single owner. Always provision a second owner. The system will refuse to demote or remove the only one.
Error handling #
Three structured error types so you can surface failures without losing the deep-link to the chain entry:
| Error | Meaning | What to do |
|---|---|---|
PolicyDeniedError | API returned deny. Executor never ran. | Show the user a friendly message; link to err.actionId for the audit trail. |
ApprovalRejectedError | A human reviewer rejected the action. | Same as above. Often paired with a feedback prompt. |
ApprovalTimeoutError | Nobody responded within approvalTimeoutMs. | Surface a "still pending" UI; user can retry. |
import {
PolicyDeniedError,
ApprovalRejectedError,
ApprovalTimeoutError,
} from '@provedit/sdk';
try {
await provedit.run(opts, executor);
} catch (err) {
if (err instanceof PolicyDeniedError) return reply(`Blocked by policy: ${err.message}`);
if (err instanceof ApprovalRejectedError) return reply(`Reviewer denied: ${err.message}`);
if (err instanceof ApprovalTimeoutError) return reply(`No approval within ${err.timeoutMs} ms.`);
throw err;
}
What gets recorded #
Every run() produces two chain entries:
// invocation
{
"tool": "issue_refund",
"params": { "customerId": "c_123", "amountCents": 4800 },
"policyDecision": "require_approval",
"policyName": "team.payments",
"approvalRef": "apr_19shx4j",
"approvalStatus": "pending",
"actor": { "vendor": "openai", "agentName": "crm-assistant",
"userEmail": "alice@acme.com", "modelName": "gpt-4o" },
"target": { "kind": "payment", "ref": "c_123", "summary": "$48.00 to ACME" },
"sessionId": "sess_xyz",
"requestedAt": "2026-05-07T14:22:11.000Z"
}
// result (linked via parentActionId)
{
"tool": "issue_refund.result",
"parentActionId": "act_...",
"outputHash": "sha256:...",
"ok": true,
"durationMs": 412
}
Outputs are SHA-256 hashed, not stored verbatim, so you can prove later "this call returned this exact value" without us holding your data.
Verify the chain #
The chain is independently verifiable. The verifier ships as @provedit/verifier on npm (Apache-2.0, source at github.com/provedit/verifier). Run it without installing:
# one-shot, no install
npx @provedit/verifier verify --tenant <tenantId> --from 1 --to 1000
# or install globally
npm install -g @provedit/verifier
provedit-verify login
provedit-verify verify --from 1 --to 1000
login caches a session at ~/.provedit/auth.json after authenticating against app.provedit.ai (handles MFA), then verify lets you pick from the tenants where your account has the auditor role or higher. Pass --no-auth to skip the session and use only public endpoints.
The CLI runs five checks, in order. A single failure short-circuits and exits non-zero:
| Check | What it proves |
|---|---|
| continuity | Every entryHash is the SHA-256 of its canonical contents and prevEntryHash chains back to the previous entry or GENESIS. |
| coverage | No gaps in chainSeq over the requested range. Every action that was decided was also recorded. |
| merkle | For every batch overlapping the range, the root recomputed from the included entryHash leaves matches batch.merkleRoot. |
| signature | Each batch was signed by the tenant's signing key whose validity window covers signedAt (ES256 / ECDSA P-256). |
| anchor | Periodic roots anchored externally match the chain entry they reference. |
Pass --json for machine-readable output in CI; the README has a copy-pasteable GitHub Actions workflow. Source files are listed in the repo README if you want to audit the verifier itself before running it.
Cryptographic Bill of Materials #
Every tenant publishes a signed Cryptographic Bill of Materials (CBOM) listing every algorithm and active signing key Provedit uses to produce that tenant's evidence. The format is CycloneDX 1.6 with the cryptographic-asset component type, which is what procurement teams, GRC tools, and PQC-migration inventories expect (US NSM-10 / OMB M-23-02, EU CRA Annex I).
It is a single GET, signed with the tenant's ES256 chain-head key, and there is nothing to install:
# public endpoint, no auth
curl https://api.provedit.ai/v1/cbom/<tenantId> -o cbom.cdx.json
# inspect
jq '.metadata.component, .components[] | select(.type == "cryptographic-asset") | {name, "crypto": .cryptoProperties.algorithmProperties}' cbom.cdx.json
What's in the document:
- Algorithms: ECDSA-P256-SHA256 (chain signing), SHA-256 (entry & Merkle hashing), HMAC-SHA-256 (session keys), PBKDF2-HMAC-SHA-256 (key derivation), JCS-RFC-8785 (canonicalization), TLS-1.3 (transport).
- Keys: every active signing key for the tenant, with key ID, curve, validity window, and Azure Key Vault key reference. Rotated keys remain listed for as long as they cover entries in the chain.
- Signature: a JSF-style ES256 signature over the canonicalised document. The same public key bundle that verifies your evidence chain verifies the CBOM.
Console users can download the file from Settings → Cryptographic Bill of Materials; auditors and procurement can hit the endpoint directly and verify it offline. The endpoint is rate-limited and read-only; no tenant data leaks beyond the algorithms and public key material the chain already exposes.
Troubleshooting #
Error: missing PROVEDIT_AGENT_KEY on first call.process.env.PROVEDIT_AGENT_KEY is loaded before createClient runs. If you use dotenv, call config() at the top of your entrypoint.fetch failed, ECONNREFUSED, or ENOTFOUND api.provedit.ai when calling provedit.run.api.provedit.ai (CNAME to ca-provedit-api-prod.livelyglacier-85d9c2d2.westeurope.azurecontainerapps.io). On a fresh machine the DNS record may take a minute to propagate; if you see ENOTFOUND, flush your resolver (ipconfig /flushdns on Windows, sudo dscacheutil -flushcache on macOS) or wait 60 s and retry. In containers, confirm egress to *.azurecontainerapps.io:443 and api.provedit.ai:443 is allowed. ECONNREFUSED typically means PROVEDIT_API_URL is pointing at localhost; double-check the env var.PolicyNotFoundError: policy 'X' has no active version on provedit.run.X in the portal, but that policy has no active version on this tenant (it was deleted, or its active pointer was cleared). The SDK fails closed rather than silently falling back to provedit.mcp.default - that would turn a missing policy into a wide-open allow. Either re-create / re-activate the policy in app.provedit.ai/policies, or rebind the key to a different policy in app.provedit.ai/account/agent-keys.ApprovalTimeoutError.provedit.mcp.default active. Tools under the smoke.* prefix are auto-allowed so the quick start works on day zero. If you used a tool name like refund or deploy.app for your smoke test, the * catch-all routes it to require_approval; either rename to smoke.<something>, or open app.provedit.ai/approvals and approve it manually the first time.Assertion failed: !(handle->flags & UV_HANDLE_CLOSING) after the smoke test exits on Windows.await provedit.close() at the end of the script to drain it cleanly. The chain entry was recorded correctly either way.node --watch server.js reloads when you save a smoke script in the same repo.node --watch reloads on any file change under the project. Stop the dev server before running scripts that import from src/, or run smoke scripts under a separate working directory / script entry that does not share the watch tree.ApprovalTimeoutError.operator role on this tenant (see Roles & permissions). Webhook approvals: verify the destination URL is set under Settings → Webhooks.node /abs/path/services/mcp-proxy/dist/index.js -- npx -y @modelcontextprotocol/server-everything. Most failures are missing env vars or a wrong absolute path. Use absolute paths everywhere; MCP clients do not expand ~.tools/call succeeds but no entry shows up in the console.PROVEDIT_TENANT_ID matches the tenant you're viewing. Filter by vendor in the console; it's likely under the right tenant but a different vendor tag than you expected.deny for an action you expected to allow.provedit.mcp.default only ships allow rules for the legacy dotted-name tools (fs.*, shell.exec, http.fetch) and smoke.*; everything else, including the current underscore-named provedit-mcp tools, falls through to require_approval via the * catch-all. Author your own policy and activate it for full coverage.continuity: FAIL.--from and --to bounds. Continuity is global per tenant; if you slice across tenants the chain will look broken. Open app.provedit.ai/chain to inspect the entry around the failing index and confirm prevHash matches.FAQ #
Do I have to wrap every call?
No. Wrap the actions you actually want to audit (writes, deploys, payments, IAM, customer-data reads). Pure reads of public data don't need wrapping unless you want them in the timeline for context.
Does the SDK call out to the network on every action?
Yes, once per run() for the policy decision and once for the result. Both are small JSON requests. Median round-trip is well under 100 ms from a major cloud region. If the API is unreachable, the SDK fails closed by default (configurable).
Where is my data stored?
Chain entries (the structured metadata, signatures, and output hashes) are stored in your tenant's region of Provedit Cloud. The actual outputs are never stored. Only their SHA-256 hash is kept, so you can prove a later copy of the output matches what the agent produced.
Can I self-host?
Hosted-only today. A self-managed deployment is on the roadmap for customers with a hard residency or air-gap requirement; if that is you, email hello@provedit.ai and we will scope it with you directly.
Does this work for non-Node services?
The SDK is Node-only today. Python, Go, and Rust SDKs are next on the roadmap. In the meantime, every Node service can wrap calls and the MCP proxy works regardless of the language behind your MCP server.
What about secrets in params?
The SDK records params as-is. Strip secrets before passing (redact them in your dispatcher) or set the params.redact array in the call options to skip specific keys.
Does it slow my agent down?
Two API round-trips per wrapped action. For human-approval flows, you wait however long the human takes. There is no measurable overhead on the executor itself.
Glossary #
| Term | Means |
|---|---|
| Action | A single thing an agent did or tried to do. One provedit.run() = one action (with two chain entries). |
| Actor | The combination of vendor, agent id, user email, and model behind an action. |
| Agent key | Bearer token that identifies your tenant and agent to the Provedit API. |
| Chain | Append-only, per-tenant log of signed entries linked by prevHash. |
| Policy bundle | A versioned set of rules. Each bundle has a hash; that hash is locked into every action it decides. |
| Session | A grouping id you choose (chat thread, workflow run) that ties related actions together. |
| Anchor | External commitment of a chain root (e.g. to a public ledger), so even Provedit can't rewrite history undetected. |
API reference #
createClient(config)
| Field | Type | Notes |
|---|---|---|
apiUrlrequired | string | Base URL of the Provedit API. Use https://api.provedit.ai. |
agentKeyrequired | string | Bearer token (tenant + agent). Bind the policy on this key in the portal. |
agentIdrequired | string | Stable id of the service / agent, e.g. refund-agent. |
vendor | string | Model vendor, e.g. openai, anthropic, cursor. Surfaces in the console. |
modelName | string | Model id used by the agent, e.g. gpt-4o-mini. |
One more constructor option exists for production teams behind an HTTP gateway with a short idle cap: approvalTimeoutMs (default 12h). Set it just under your gateway's idle limit. Everything else (per-client policy pin, default user email, custom fetch) was removed in 0.3.0: policy belongs in the portal on the agent key, and user identity belongs on the call site that has the user in scope.
provedit.run(opts, executor)
| Field | Type | Notes |
|---|---|---|
toolrequired | string | Logical tool name, snake_case verb_noun, e.g. issue_refund. |
params | object | Recorded as-is on the invocation entry. Redact secrets before passing. |
target | { kind, ref, summary } | What the action affects. summary is what a reviewer reads first. |
sessionId | string | Shared across one workflow / conversation; groups the timeline. |
actor | { userEmail?, vendor?, modelName?, agentName? } | Optional per-call override. Set actor.userEmail only when the surrounding code already has an authenticated end-user in scope; otherwise omit and let agentId be the accountable principal. |
Returns { actionId, output, decision }. Throws PolicyDeniedError, ApprovalRejectedError, or ApprovalTimeoutError on the unhappy paths.
OpenAI helpers
wrapOpenAITools(provedit, defs)→{ toolMap, dispatchAll }. Each def hasname,targetKind,describe,handler.toToolMessage(result)converts a dispatch result into an OpenAItoolmessage.
Ship checklist #
PROVEDIT_AGENT_KEYstored in your secret manager, not in code.- Every sensitive tool wrapped with
provedit.run()or registered viawrapOpenAITools. - Policy errors caught and surfaced to the user (not swallowed).
sessionIdset per conversation / workflow.- Tested the approval flow end-to-end, including the deny path.
- Verified one entry from your tenant in the console.
- Optional: ran the
@provedit/verifierCLI against your own chain (npx @provedit/verifier verify).
Still stuck? #
Email hello@provedit.ai with your tenant id (visible in the console under Settings) and a short description. Average first reply: under one business day.