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:

You write three lines around the action you care about. We do the rest.

How to read this guide The sections are ordered as you will actually need them. Pick your path tells you which integration fits your stack. Mental model is the one diagram that explains every snippet that follows. Prerequisites lists what to have open in another tab. From there you either hand Bootstrap with your coding agent to Cursor / Claude Code / Copilot and let it wire everything in, or you walk the manual 5-minute quick start yourself. Recipes are drop-in patterns for the framework you use. Building production agents is the hard-won lessons section to read before you ship. MCP integration, Policies, Roles, and Verify are reference material you come back to as the surface grows. Every section assumes the one above it.

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.

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.

Your agent SDK or MCP client Provedit gate 1. record intent 2. policy decide 3. wait for approval allow execute the action require_approval human clicks Approve deny throw, never run Signed chain intent + result

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 #

Tip Don't have a key yet? You can still install the SDK and read the snippets. Calls will throw a clear "no agent key" error until you set 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.

Before you paste Mint an agent key at app.provedit.ai/account/agent-keys and have it ready. The agent will ask you for it (or for the name of the env var that holds it).
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.

  1. Install the SDK

    From npm (Node 20+):

    npm install @provedit/sdk

    Package page: npmjs.com/package/@provedit/sdk. Source: github.com/provedit/sdk-js. The import specifier is @provedit/sdk.

  2. 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
  3. 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
    });
  4. 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.run throws before your code executes. Approval requests still go to humans (the operator console assignees), even when the action itself has no end-user attached.

  5. Confirm it landed

    Open app.provedit.ai/agents. Your agentId shows up in the list within a second or two, with a count of 1 and 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.

  6. Author a policy (required to actually gate)

    Until you create and activate a policy, the API returns a synthetic allow for 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.

    1. Open app.provedit.ai/policies and click New policy.
    2. 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 of allow, require_approval, or deny.
    3. Save and click Activate on the version.
    4. 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 policyName in 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_approval for that tool, the call now blocks until you click Approve in the operator console. If it says deny, provedit.run throws PolicyDeniedError before your code executes. That is the gate working.

Tip A fresh tenant has one built-in policy (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:

  1. Console. The action appears under Operations within a second.
  2. Return value. actionId is the chain id; deep-link with https://app.provedit.ai/timeline?q=<actionId>.
  3. 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:

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:

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:

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" }
  ]
}

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:

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:

  1. Asks the Provedit API for a policy decision.
  2. Submits the action as an evidence entry, locked to the policy bundle hash that decided it.
  3. Blocks on operator approval when policy says so.
  4. Forwards to the upstream server (or short-circuits on deny / failure: fail-closed).
  5. 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.

Why this is the wedge The proxy is the only place where you can both enforce a policy decision and sign the evidence in one atomic step. Every other collector (log tailer, host sensor) is post-hoc.

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.

Tip Restart the MCP client after editing the config. In VS Code, run Ctrl+Shift+PMCP: Restart servers.

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
ToolArgsRecords
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:

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:

Prompts capability

The server advertises prompts/list and prompts/get so hosts can surface canonical system messages as one-click inserts:

PromptPurpose
provedit.system-rulesThe canonical system prompt that tells the model to route everything through MCP tools and respect policy decisions. Inject once at session start.
provedit.bootstrap-sdkMulti-step plan for integrating @provedit/sdk into the current repo using only MCP tools.
provedit.explain-blockTemplate 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:

PolicyWhat it doesUse when
provedit.mcp.defaultDay-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.

  1. Open app.provedit.ai/account/agent-keys.
  2. On the row for the agent's key, pick the policy from the dropdown.
  3. 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

  1. SDK or proxy receives require_approval from the API.
  2. An approval request appears in the operator console (and via webhook, if configured).
  3. A reviewer clicks Approve or Reject.
  4. 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" }
  ]
}

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

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

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

RoleCan doTypical 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

Capabilityviewerauditoroperatoradminowner
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

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

PersonaRoleWhy
Account owner / platform leadownerNeeds to manage other owners and survive personnel changes. Keep at least two.
Tenant admins (rotated)adminManage members, agent keys, anchors. No need for owner unless they manage other owners.
On-call reviewer / approveroperatorApproves and rejects pending actions. Cannot invite users or rotate keys.
Internal audit / GRCauditorCan export and verify the chain without touching policies or approvals.
Engineering manager / exec viewerviewerRead-only access to dashboards. No export, no approval rights.
External assessor (time-boxed)auditorGrant via invite, then remove the membership when the engagement ends.
CI / agent service accountoperatorLowest role that can submit actions and decide on its own approvals if policy permits.

Operating rules of thumb

Common mistakes

Error handling #

Three structured error types so you can surface failures without losing the deep-link to the chain entry:

ErrorMeaningWhat to do
PolicyDeniedErrorAPI returned deny. Executor never ran.Show the user a friendly message; link to err.actionId for the audit trail.
ApprovalRejectedErrorA human reviewer rejected the action.Same as above. Often paired with a feedback prompt.
ApprovalTimeoutErrorNobody 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:

CheckWhat it proves
continuityEvery entryHash is the SHA-256 of its canonical contents and prevEntryHash chains back to the previous entry or GENESIS.
coverageNo gaps in chainSeq over the requested range. Every action that was decided was also recorded.
merkleFor every batch overlapping the range, the root recomputed from the included entryHash leaves matches batch.merkleRoot.
signatureEach batch was signed by the tenant's signing key whose validity window covers signedAt (ES256 / ECDSA P-256).
anchorPeriodic 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:

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.
Make sure 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.
The API runs in Azure West Europe at 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.
The agent key your service is using is bound to policy 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.
First smoke test hangs and ends in ApprovalTimeoutError.
A fresh tenant ships with 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.
Cosmetic. The Node fetch keep-alive pool is closing as the process exits. Call 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.
Not Provedit-specific: 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.
Approval never arrives; the call hangs until ApprovalTimeoutError.
Open the operator console under Pending approvals. Confirm the user has at least operator role on this tenant (see Roles & permissions). Webhook approvals: verify the destination URL is set under Settings → Webhooks.
MCP client (Cursor / Copilot) shows the proxy as "stopped" or "errored".
Run the proxy command manually in a terminal: 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.
Confirm 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.
Policy says deny for an action you expected to allow.
Check which policy is active for the tenant (operator console → Policies). The default 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.
Verifier reports continuity: FAIL.
Make sure you passed the right --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 #

TermMeans
ActionA single thing an agent did or tried to do. One provedit.run() = one action (with two chain entries).
ActorThe combination of vendor, agent id, user email, and model behind an action.
Agent keyBearer token that identifies your tenant and agent to the Provedit API.
ChainAppend-only, per-tenant log of signed entries linked by prevHash.
Policy bundleA versioned set of rules. Each bundle has a hash; that hash is locked into every action it decides.
SessionA grouping id you choose (chat thread, workflow run) that ties related actions together.
AnchorExternal commitment of a chain root (e.g. to a public ledger), so even Provedit can't rewrite history undetected.

API reference #

createClient(config)

FieldTypeNotes
apiUrlrequiredstringBase URL of the Provedit API. Use https://api.provedit.ai.
agentKeyrequiredstringBearer token (tenant + agent). Bind the policy on this key in the portal.
agentIdrequiredstringStable id of the service / agent, e.g. refund-agent.
vendorstringModel vendor, e.g. openai, anthropic, cursor. Surfaces in the console.
modelNamestringModel 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)

FieldTypeNotes
toolrequiredstringLogical tool name, snake_case verb_noun, e.g. issue_refund.
paramsobjectRecorded 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.
sessionIdstringShared 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

Ship checklist #

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.