Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ Product release notes: [docs.proofable.me/changelog](https://docs.proofable.me/c

## [Unreleased]

## [0.1.12] - 2026-11-04

### Added

- **Your app's users can talk to your agent.** `ProofableSignIn` signs them in with Proofable, the same sign-in every MCP host uses: `start()` sends the person to Proofable, `finish()` exchanges the code for their tokens, `refresh()` renews them, and `register()` records your callback once. Then `client.agentChat({ agentId, agentWallet })` as the person runs your public agent inside their own profile: their records, and nothing of yours in the conversation.
- **Your app pays for those chats.** `client.sponsorApp()` and `proofable sponsor` turn sponsorship on once: your app may spend up to a daily cap of your credits on its users' checks and chats. A client with `appId` and `billingWallet` then carries it, and every spend record names the app. `fetchSponsorGrant` takes `scope: 'agent-chat'`.
- **`client.me()`** says who the credential belongs to.

## [0.1.11] - 2026-11-01

### Added
Expand Down Expand Up @@ -192,7 +200,8 @@ The `@proofable/sdk` package continues the line published as `@neus/sdk`. The no
| [1.1.0](https://www.npmjs.com/package/@neus/sdk/v/1.1.0) | 2026-05-26 | First public SDK release on npm: OAuth-first setup, the CLI, public MCP tools including encrypted Vault secrets, and the Claude Code plugin with the trust workflow skill. |
| [1.0.12](https://www.npmjs.com/package/@neus/sdk/v/1.0.12) | 2026-05-26 | First `@neus/sdk` release with the hosted MCP CLI (`neus setup`, `neus auth`, import/export) and the OAuth browser flow. |

[Unreleased]: https://github.com/proofable/sdk/compare/v0.1.11...HEAD
[Unreleased]: https://github.com/proofable/sdk/compare/v0.1.12...HEAD
[0.1.12]: https://github.com/proofable/sdk/compare/v0.1.11...v0.1.12
[0.1.11]: https://github.com/proofable/sdk/compare/v0.1.10...v0.1.11
[0.1.10]: https://github.com/proofable/sdk/compare/v0.1.9...v0.1.10
[0.1.9]: https://github.com/proofable/sdk/compare/v0.1.8...v0.1.9
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,50 @@ turn.credits; // what the turn spent

An action the agent's permission does not allow is refused before it runs, in every runtime that calls it. Revoke the permission once and the next call stops. Read your spend any time with `client.getUsage({ from: '2026-11-01' })`.

## Let your app's users talk to your agent

Sign your users in with Proofable, then make the same call as them. Each person talks to your public agent inside their own Proofable profile: their records, and nothing of yours in the conversation. Your agent's instructions and the records you made public are what it knows.

```js
import { ProofableClient, ProofableSignIn } from '@proofable/sdk';

// clientId: what register() returned the first time, kept with your app's settings.
const signIn = new ProofableSignIn({ redirectUri: 'https://your.app/proofable/callback', clientId: settings.proofableClientId });

// 1. Send the person to Proofable. Keep `state` and `verifier` for when they come back (a signed cookie works).
const started = await signIn.start();
redirect(started.url);

// 2. They are back at your callback with `code` and `state` in the URL.
const tokens = await signIn.finish({ code: query.code, verifier: kept.verifier, state: query.state, expectedState: kept.state });

// 3. Act as the person. Your app pays: appId and billingWallet make every turn your organization's spend.
const person = new ProofableClient({
apiKey: tokens.accessToken,
appId: 'your-app',
billingWallet: '0xYourAccount',
appOrigin: 'https://your.app',
});
const who = await person.me(); // { handle, address, ... }
const turn = await person.agentChat({
agentId: 'my-agent',
agentWallet: '0xYourAccount', // your account: the agent runs in the person's own profile
turnId: `u-${yourUserKey}-${Date.now()}`, // your key for the person; every spend record carries it
messages: [{ role: 'user', content: 'Where does the case start?' }],
onEvent: (type, data) => type === 'answer.delta' && respond(data.text),
});
```

**Sign-in.** `start()` with no `clientId` registers your callback with Proofable and returns the id; store it with your app's settings and pass it on every later instance. Refresh with `signIn.refresh(tokens.refreshToken)` before the token expires.

**Who pays.** Your app does. Turn it on once, signed in as yourself:

```bash
npx -y @proofable/sdk sponsor --app your-app --origin https://your.app --credits-per-day 2000
```

From then on a client built with `appId` and `billingWallet` carries your sponsorship: your organization pays for your users' checks and their chats with your agents, within the daily cap you set, and every spend record names your app and your `turnId`, so you can set your own limits per person. Without `appId` and `billingWallet`, each person spends their own credits. A record the agent keeps for a person is the person's own and costs one credit from their own allowance, which every account has. In code, the same step is `client.sponsorApp({ appId, origin, creditsPerDay })`.

## Add a check to your app

Verify someone once, then reuse that proof on every later check.
Expand Down
46 changes: 45 additions & 1 deletion cli/proofable.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1004,11 +1004,21 @@ function parseArgs(argv) {
expiresAt: '',
gateCommand: '',
subject: '',
gateTarget: ''
gateTarget: '',
app: '',
origin: '',
creditsPerDay: ''
};

for (let index = 1; index < argv.length; index += 1) {
const token = argv[index];
if (token === '--app' || token === '--origin' || token === '--credits-per-day') {
const value = argv[index + 1];
if (!value) throw new Error(`${token} requires a value`);
options[token === '--app' ? 'app' : token === '--origin' ? 'origin' : 'creditsPerDay'] = value.trim();
index += 1;
continue;
}
if (token === '--json') {
options.json = true;
continue;
Expand Down Expand Up @@ -1122,6 +1132,7 @@ function printUsage(exitCode = 0) {
' import <src> Create an agent from a repo (URL or owner/name), folder, MCP URL, or agent card, then mount it',
' gate check <file> --subject <id> Check a Gate Policy file against a subject',
' gate get <gateId> Read a published gate snapshot',
' sponsor --app <id> --origin <https://…> --credits-per-day <n> Let your app pay for its users\' checks and chats with your agents',
' help Show this message',
'',
'Options:',
Expand Down Expand Up @@ -3158,6 +3169,35 @@ async function runDisconnect(options) {
}
}

/**
* `proofable sponsor --app <id> --origin <https://…> --credits-per-day <n>`: one signed record on your
* account that lets your app pay for its users' checks and their chats with your agents, up to the
* daily cap. Signs in the way `doctor --live` does: --access-key, the installed key, or the CLI store.
*/
async function runSponsor(options) {
const usage = 'Usage: proofable sponsor --app <id> --origin <https://your.app> --credits-per-day <n> [--json]';
if (!options.app || !options.origin || !options.creditsPerDay) throw new Error(usage);
const scope = resolveScope(options);
const credential = resolveLiveCredential(options, scope, process.cwd());
if (!credential?.key) {
throw new Error('Sign in first: `proofable auth --oauth`, or pass --access-key with your own profile access key.');
}
const { ProofableClient } = await import('../client.js');
const client = new ProofableClient({ apiKey: credential.key });
const result = await client.sponsorApp({
appId: options.app,
origin: options.origin,
creditsPerDay: Number(options.creditsPerDay)
});
if (options.json) {
printJson({ command: 'sponsor', ...result });
return;
}
logStep('ok', 'sponsor', `${result.appId} at ${result.origin} may spend up to ${result.creditsPerDay} credits a day on its users' checks and chats`);
logStep('next', 'build', `new ProofableClient({ apiKey, appId: '${result.appId}', billingWallet: '<your account>', appOrigin: '${result.origin}' })`);
logStep('info', 'record', `https://proofable.me/proof/${result.qHash}`);
}

async function runGate(options) {
const sub = String(options.gateCommand || '').trim().toLowerCase();
if (sub !== 'check' && sub !== 'get') {
Expand Down Expand Up @@ -3276,6 +3316,10 @@ async function main() {
await runGate(options);
return;
}
if (command === 'sponsor') {
await runSponsor(options);
return;
}

process.stderr.write(`Unknown subcommand: ${command}\n`);
printUsage(1);
Expand Down
94 changes: 91 additions & 3 deletions client.js
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ export class ProofableClient {
return null;
}

async _resolveSponsorGrantHeaders(verifierIds = []) {
async _resolveSponsorGrantHeaders(verifierIds = [], { scope = 'sponsored-verification', fresh = false } = {}) {
const appId = typeof this.config.appId === 'string' ? this.config.appId.trim() : '';
const orgWallet = this._getBillingWallet();
if (!appId || !orgWallet) {
Expand All @@ -504,9 +504,11 @@ export class ProofableClient {
const normalizedVerifierIds = Array.isArray(verifierIds)
? verifierIds.map((v) => String(v || '').trim()).filter(Boolean).slice(0, 25)
: [];
const cacheKey = `${appId}:${orgWallet}:${normalizedVerifierIds.join(',')}`;
const cacheKey = `${appId}:${orgWallet}:${scope}:${normalizedVerifierIds.join(',')}`;
const now = Date.now();
// A grant is accepted once. A call that must carry its own takes a fresh one every time.
if (
!fresh &&
this._sponsorGrantCache &&
this._sponsorGrantCache.key === cacheKey &&
this._sponsorGrantCache.expMs > now + 30_000
Expand All @@ -520,6 +522,7 @@ export class ProofableClient {
appId,
orgWallet,
verifierIds: normalizedVerifierIds,
scope,
origin
});

Expand Down Expand Up @@ -2111,6 +2114,11 @@ export class ProofableClient {
* Authenticate the client with a server key (`apiKey`) on a server, or a signed-in
* session token in a browser. Never ship a server key in browser code.
*
* Who pays: the signed-in account, unless the client carries `appId` and `billingWallet`.
* Then it sends your app's sponsor grant with the turn and your organization pays, within
* the daily cap you set with `sponsorApp()`, with the spend recorded under the app. That is
* how an app pays for its users' chats with your agent.
*
* @param {object} params
* @param {Array<{ role: 'user'|'assistant', content: string }>} params.messages the last one from the user
* @param {string} [params.agentId] run as this agent; omit to run as yourself
Expand All @@ -2129,11 +2137,12 @@ export class ProofableClient {
if (!Array.isArray(body.messages) || body.messages.length === 0) {
throw new ValidationError('messages must be a non-empty array, the last one from the user', 'messages', body.messages);
}
const sponsorHeaders = await this._resolveSponsorGrantHeaders([], { scope: 'agent-chat', fresh: true });
let response;
try {
response = await fetch(`${this.baseUrl}/api/v1/agent/chat/turn`, {
method: 'POST',
headers: { ...this.defaultHeaders, Accept: 'text/event-stream' },
headers: { ...this.defaultHeaders, ...sponsorHeaders, Accept: 'text/event-stream' },
body: JSON.stringify(body),
signal
});
Expand Down Expand Up @@ -2176,6 +2185,85 @@ export class ProofableClient {
return final;
}

/**
* Let your app sponsor usage: the checks your users run and the chats they have with your
* agents are billed to your organization, within a daily credit cap, and recorded under the
* app. One signed record on your account, made with your own access key or sign-in; after
* it, a client built with `appId` and `billingWallet` carries your sponsorship on every call.
*
* @param {object} params
* @param {string} params.appId your app's id, letters, digits, `.`, `_`, `:` or `-`
* @param {string} params.origin the https origin your app calls from
* @param {number} params.creditsPerDay the most credits the app may spend in a UTC day
* @param {Array<'sponsored-verification'|'agent-chat'>} [params.scopes] what it sponsors; both by default
* @returns {Promise<{ qHash: string, appId: string, origin: string, creditsPerDay: number, scopes: string[] }>}
*/
async sponsorApp(params = {}) {
const appId = String(params.appId || '').trim();
if (!/^[a-zA-Z0-9._:-]{1,64}$/.test(appId)) {
throw new ValidationError('appId must be 1-64 characters of letters, digits, ".", "_", ":" or "-"', 'appId', params.appId);
}
let origin;
try {
const url = new URL(String(params.origin || '').trim());
if (url.protocol !== 'https:') throw new Error('https');
origin = url.origin;
} catch {
throw new ValidationError('origin must be the https origin your app calls from', 'origin', params.origin);
}
const creditsPerDay = Number(params.creditsPerDay);
if (!Number.isInteger(creditsPerDay) || creditsPerDay < 1 || creditsPerDay > 999_999_999) {
throw new ValidationError('creditsPerDay must be a whole number of credits', 'creditsPerDay', params.creditsPerDay);
}
const allowed = ['sponsored-verification', 'agent-chat'];
const scopes = Array.isArray(params.scopes) && params.scopes.length > 0
? [...new Set(params.scopes.map((s) => String(s || '').trim().toLowerCase()))]
: allowed;
const unknown = scopes.find((s) => !allowed.includes(s));
if (unknown) throw new ValidationError(`scopes may hold only ${allowed.join(' and ')}`, 'scopes', params.scopes);

const issuer = await this._makeRequest('GET', '/api/v1/sponsor/issuer-address');
const issuerAddress = String(issuer?.issuerAddress || issuer?.data?.issuerAddress || '').trim().toLowerCase();
if (!EVM_ADDRESS_RE.test(issuerAddress)) {
throw new ApiError('Proofable did not return its sponsorship issuer', 502, 'SPONSOR_ISSUER_UNAVAILABLE', issuer);
}
const me = await this.me();
const owner = String(me?.primaryAccount || me?.address || '').trim().toLowerCase();
if (!EVM_ADDRESS_RE.test(owner)) {
throw new ValidationError('Sign the client in with your own access key or sign-in before sponsoring an app', 'apiKey', null);
}
const response = await this._makeRequest('POST', '/api/v1/verification', {
walletAddress: owner,
verifierIds: ['agent-delegation'],
data: {
controllerWallet: owner,
agentWallet: issuerAddress,
agentId: `sponsor-${appId}`,
scope: 'sponsor-grants',
allowedActions: ['read_context'],
permissions: [`app:${appId}`, `origin:${origin}`, `cap:creditsPerDay=${creditsPerDay}`, `scopes:${scopes.join(',')}`]
},
signedTimestamp: Date.now(),
options: { privacyLevel: 'private' }
});
const qHash = String(response?.data?.qHash || response?.qHash || '');
if (!response?.success || !qHash) {
throw new ApiError(`Sponsorship could not be recorded: ${response?.error?.message || 'unknown error'}`, 502, 'SPONSOR_SETUP_FAILED', response);
}
return { qHash, appId, origin, creditsPerDay, scopes };
}

/**
* Who this client is signed in as: the profile behind the access key or the person's token
* from `ProofableSignIn`. `authenticated` is false for a client with no credential.
*
* @returns {Promise<{ authenticated: boolean, address?: string, primaryAccount?: string, handle?: string, profileSummary?: Record<string, unknown>, [key: string]: unknown }>}
*/
async me() {
const response = await this._makeRequest('GET', '/api/v1/auth/me');
return response?.data ?? { authenticated: false };
}

/**
* Your credits and spend, readable without opening Proofable: the balance, recent spend
* events, and a summary by line (`byMode`) and by UTC day (`debitedByDay`).
Expand Down
7 changes: 7 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ export {

export { fetchSponsorGrant } from './sponsor.js';

export {
ProofableSignIn,
PROOFABLE_SIGN_IN_ISSUER,
PROOFABLE_SIGN_IN_RESOURCE,
PROOFABLE_SIGN_IN_SCOPE
} from './sign-in.js';

export {
RUNTIME_MOUNT_SCHEMA,
normalizeWallet,
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@proofable/sdk",
"version": "0.1.11",
"version": "0.1.12",
"description": "SDK and CLI for verification gates, reusable proof, and agent permissions.",
"bin": {
"proofable": "cli/proofable.mjs"
Expand Down Expand Up @@ -47,6 +47,11 @@
"import": "./runtime-adapters.js",
"require": "./cjs/runtime-adapters.cjs"
},
"./sign-in": {
"types": "./types.d.ts",
"import": "./sign-in.js",
"require": "./cjs/sign-in.cjs"
},
"./sponsor": {
"types": "./types.d.ts",
"import": "./sponsor.js",
Expand Down Expand Up @@ -74,7 +79,7 @@
"format": "prettier --write \"**/*.js\"",
"build": "npm run build:widgets && npm run build:cjs",
"build:widgets": "npx esbuild widgets/verify-gate/VerifyGate.jsx widgets/verify-gate/ProofBadge.jsx --bundle --platform=browser --format=esm --outdir=widgets/verify-gate/dist --jsx=automatic --legal-comments=none --external:react --external:react-dom --external:react/jsx-runtime --external:@proofable/sdk/client --external:@proofable/sdk/utils",
"build:cjs": "npx esbuild index.js client.js utils.js errors.js gates.js runtime-mount.js runtime-adapters.js sponsor.js brand-mark.js --bundle --platform=node --format=cjs --outdir=cjs --out-extension:.js=.cjs --legal-comments=none --external:ethers --external:@zkpassport/sdk --external:react --external:react-dom --external:react/jsx-runtime",
"build:cjs": "npx esbuild index.js client.js utils.js errors.js gates.js runtime-mount.js runtime-adapters.js sponsor.js sign-in.js brand-mark.js --bundle --platform=node --format=cjs --outdir=cjs --out-extension:.js=.cjs --legal-comments=none --external:ethers --external:@zkpassport/sdk --external:react --external:react-dom --external:react/jsx-runtime",
"prepack": "npm run build",
"prepublishOnly": "npm run lint && npm test",
"proofable": "node cli/proofable.mjs"
Expand Down Expand Up @@ -163,6 +168,7 @@
"errors.js",
"gates.js",
"sponsor.js",
"sign-in.js",
"brand-mark.js",
"runtime-mount.js",
"runtime-adapters.js",
Expand Down
Loading
Loading