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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ Product release notes: [docs.proofable.me/changelog](https://docs.proofable.me/c

## [Unreleased]

### Added

- **`client.agentChat()` talks to a hosted agent from any app or runtime.** Name the agent and send the conversation; the turn runs on Proofable as that agent, under the delegation you signed for it, with every tool call checked before it runs. Events stream to `onEvent`, and the call resolves with the answer, the records the agent read as `citations`, and the credits it spent. Nothing to deploy.
- **`client.getUsage()` reads your credits and spend.** The balance, recent spend events, and spend by line and by UTC day, for any period with `from` and `to`.

## [0.1.10] - 2026-10-28

### Changed
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,28 @@ Paste this into Claude Code, Cursor or Codex.
Set up Proofable for this project. Run `npx -y @proofable/sdk setup`. Then run `npx -y @proofable/sdk auth --oauth` and wait for me to sign in through the browser. Then run `npx -y @proofable/sdk import . --apply <host>`, replacing <host> with claude, cursor or codex to match the tool you run in. Show me what the agent may do and its page link.
```

## Talk to your agent from anywhere

Your agent runs on Proofable, so any app, server or runtime can talk to it with one call. Nothing to deploy. It answers as itself, inside the limits you signed, and cites the records it read.

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

const client = new ProofableClient({ apiKey: process.env.PROOFABLE_ACCESS_KEY });

const turn = await client.agentChat({
agentId: 'my-agent',
messages: [{ role: 'user', content: 'Summarize this week for the client.' }],
onEvent: (type, data) => type === 'answer.delta' && process.stdout.write(data.text),
});

turn.answer; // the agent's answer
turn.citations; // the records it read: { qHash, title, facet, visibility }
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' })`.

## Add a check to your app

Verify someone once, then reuse that proof on every later check.
Expand Down Expand Up @@ -147,6 +169,8 @@ A terminal that is not signed in gets a browser link instead.
| `client.createWalletLinkData()` | Wallet-link payloads |
| `client.getVerifiers()` | List live verifier ids |
| `client.getVerifierCatalog()` | Full verifier catalog with access levels |
| `client.agentChat()` | Talk to a hosted agent: one turn, run as the agent, with citations |
| `client.getUsage()` | Your credits and spend, by line and by day |
| `client.isHealthy()` | Ping the API health endpoint |

```js
Expand Down
126 changes: 126 additions & 0 deletions client.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,27 @@ const FALLBACK_PUBLIC_VERIFIER_CATALOG = {

const EVM_ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/;
const WALLET_LINK_RELATIONSHIP_TYPES = new Set(['primary', 'personal', 'org', 'affiliate', 'agent', 'linked']);
const USAGE_DAY_RE = /^\d{4}-\d{2}-\d{2}$/;

/** One Server-Sent Event block (`event:` and `data:` lines) as `{ event, data }`, or null. */
function parseSseBlock(block) {
let event = 'message';
const data = [];
for (const line of String(block).split('\n')) {
if (!line || line.startsWith(':')) continue;
const at = line.indexOf(':');
const field = at < 0 ? line : line.slice(0, at);
const value = at < 0 ? '' : line.slice(at + 1).replace(/^ /, '');
if (field === 'event') event = value;
else if (field === 'data') data.push(value);
}
if (data.length === 0) return null;
try {
return { event, data: JSON.parse(data.join('\n')) };
} catch {
return null;
}
}

function normalizeWalletLinkRelationshipType(value) {
const normalized = String(value || '').trim().toLowerCase();
Expand Down Expand Up @@ -2078,6 +2099,111 @@ export class ProofableClient {
return accounts[0];
}

/**
* Talk to a hosted agent. One chat turn runs on Proofable as the agent you name: its own
* instructions and skills, under the delegation you signed for it, with every tool call
* checked against that delegation before it runs. Nothing to deploy and no machine to keep.
*
* Streams each event to `onEvent(type, data)` as it arrives and resolves with the `final`
* event: `outcome`, `answer`, `citations` (the records the agent read, each one openable
* at proofable.me/proof/<qHash>), `agent`, `usage` and, for a hosted turn, `credits`.
*
* 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.
*
* @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
* @param {string} [params.agentWallet] the agent's account, when two of yours share an agentId
* @param {string} [params.systemPrompt] context from your app, passed to the agent as data
* @param {string} [params.model] 'default' (the default) or 'advanced'; with `provider`, that provider's model id
* @param {string} [params.provider] run on your own model key instead of hosted inference
* @param {string} [params.providerSecretProofQHash] the vault secret holding that key
* @param {string} [params.turnId]
* @param {(type: string, data: object) => void} [params.onEvent]
* @param {AbortSignal} [params.signal]
* @returns {Promise<object>} the `final` event
*/
async agentChat(params = {}) {
const { onEvent, signal, ...body } = params || {};
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);
}
let response;
try {
response = await fetch(`${this.baseUrl}/api/v1/agent/chat/turn`, {
method: 'POST',
headers: { ...this.defaultHeaders, Accept: 'text/event-stream' },
body: JSON.stringify(body),
signal
});
} catch (error) {
throw new NetworkError(`Network error: ${error.message}`);
}
if (!response.ok) {
let data;
try {
data = await response.json();
} catch {
data = { error: { message: 'Invalid JSON response' } };
}
throw ApiError.fromResponse(response, data);
}

// A turn can outlive any fixed timeout, so it ends on its `final` event or the caller's signal.
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let final = null;
const deliver = (block) => {
const message = parseSseBlock(block);
if (!message) return;
if (message.event === 'final') final = message.data;
if (typeof onEvent === 'function') onEvent(message.event, message.data);
};
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
let end;
while ((end = buffer.indexOf('\n\n')) >= 0) {
deliver(buffer.slice(0, end));
buffer = buffer.slice(end + 2);
}
}
if (buffer.trim()) deliver(buffer);
if (!final) throw new NetworkError('The agent turn ended before its final event.');
return final;
}

/**
* Your credits and spend, readable without opening Proofable: the balance, recent spend
* events, and a summary by line (`byMode`) and by UTC day (`debitedByDay`).
*
* @param {object} [options]
* @param {string} [options.from] first UTC day of the period, YYYY-MM-DD
* @param {string} [options.to] last UTC day of the period, YYYY-MM-DD
* @param {string} [options.appId] only spend recorded under this app or namespace
* @param {number} [options.limit] most events returned, newest first (1-500)
* @returns {Promise<{ credits: object|null, usage: { events: object[], summary: object } }>}
*/
async getUsage(options = {}) {
const { from, to, appId, limit } = options || {};
for (const [name, value] of [['from', from], ['to', to]]) {
if (value !== undefined && value !== null && !USAGE_DAY_RE.test(String(value))) {
throw new ValidationError(`${name} must be a UTC day, YYYY-MM-DD`, name, value);
}
}
const query = new URLSearchParams();
if (from) query.set('from', from);
if (to) query.set('to', to);
if (appId) query.set('appId', String(appId));
if (limit !== undefined && limit !== null) query.set('limit', String(limit));
const suffix = query.toString() ? `?${query}` : '';
const response = await this._makeRequest('GET', `/api/v1/profile/usage${suffix}`);
return response?.data ?? null;
}

async _makeRequest(method, endpoint, data = null, headersOverride = null) {
const url = `${this.baseUrl}${endpoint}`;

Expand Down
96 changes: 96 additions & 0 deletions test/agent-chat.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ProofableClient } from '../client.js';
import { ApiError, NetworkError, ValidationError } from '../errors.js';

const BOOK = `0x${'b'.repeat(64)}`;

/** A streamed response whose body arrives in the given chunks, split anywhere. */
function sseResponse(chunks, status = 200) {
const encoder = new TextEncoder();
return new Response(new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
controller.close();
}
}), { status, headers: { 'content-type': 'text/event-stream' } });
}

const event = (id, type, data) => `id: ${id}\nevent: ${type}\ndata: ${JSON.stringify(data)}\n\n`;

describe('ProofableClient.agentChat', () => {
afterEach(() => vi.unstubAllGlobals());

it('streams the turn and resolves with its final event, citations and all', async () => {
const final = {
turnId: 't1',
outcome: 'succeeded',
answer: 'Be kind.',
citations: [{ qHash: BOOK, title: 'Chapter 1', facet: 'memory', visibility: 'public' }],
agent: { agentId: 'human-alignment', agentWallet: `0x${'2'.repeat(40)}`, identityQHash: null, delegationQHash: null },
credits: { spent: 2 }
};
const body = `${event(1, 'turn.start', { turnId: 't1' }) }: keep-alive\n\n${ event(2, 'answer.delta', { text: 'Be ' })
}${event(3, 'answer.delta', { text: 'kind.' }) }${event(4, 'final', final)}`;
// Split mid-event, as a network does.
const fetch = vi.fn(async () => sseResponse([body.slice(0, 37), body.slice(37, 120), body.slice(120)]));
vi.stubGlobal('fetch', fetch);

const events = [];
const client = new ProofableClient({ apiKey: 'npk_test' });
const result = await client.agentChat({
agentId: 'human-alignment',
messages: [{ role: 'user', content: 'How does the book open?' }],
onEvent: (type, data) => events.push([type, data])
});

expect(result).toEqual(final);
expect(events.map(([type]) => type)).toEqual(['turn.start', 'answer.delta', 'answer.delta', 'final']);
const [url, init] = fetch.mock.calls[0];
expect(url).toBe('https://api.proofable.me/api/v1/agent/chat/turn');
expect(init.headers).toMatchObject({ Authorization: 'Bearer npk_test', Accept: 'text/event-stream' });
expect(JSON.parse(init.body)).toEqual({ agentId: 'human-alignment', messages: [{ role: 'user', content: 'How does the book open?' }] });
});

it('raises a refusal before the stream as an ApiError with its code', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
success: false,
error: { code: 'AGENT_DELEGATION_REQUIRED', message: 'No active delegation from this account to agent "x".' }
}), { status: 403, headers: { 'content-type': 'application/json' } })));
const client = new ProofableClient({ apiKey: 'npk_test' });
const error = await client.agentChat({ agentId: 'x', messages: [{ role: 'user', content: 'hi' }] }).catch((e) => e);
expect(error).toBeInstanceOf(ApiError);
expect(error.statusCode ?? error.status).toBe(403);
});

it('fails plainly when the stream ends without a final event', async () => {
vi.stubGlobal('fetch', vi.fn(async () => sseResponse([event(1, 'turn.start', {})])));
const client = new ProofableClient({ apiKey: 'npk_test' });
await expect(client.agentChat({ messages: [{ role: 'user', content: 'hi' }] })).rejects.toBeInstanceOf(NetworkError);
});

it('needs a conversation', async () => {
const client = new ProofableClient({ apiKey: 'npk_test' });
await expect(client.agentChat({ agentId: 'x', messages: [] })).rejects.toBeInstanceOf(ValidationError);
});
});

describe('ProofableClient.getUsage', () => {
afterEach(() => vi.unstubAllGlobals());

it('reads the balance and spend for a period', async () => {
const data = { credits: { remaining: 494 }, usage: { summary: { debitedByDay: { '2026-11-01': 4 } } } };
const fetch = vi.fn(async () => new Response(JSON.stringify({ success: true, data }), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetch);
const client = new ProofableClient({ apiKey: 'npk_test' });
expect(await client.getUsage({ from: '2026-10-01', to: '2026-11-01' })).toEqual(data);
expect(fetch.mock.calls[0][0]).toBe('https://api.proofable.me/api/v1/profile/usage?from=2026-10-01&to=2026-11-01');
});

it('refuses a period it cannot read before calling', async () => {
const fetch = vi.fn();
vi.stubGlobal('fetch', fetch);
const client = new ProofableClient({ apiKey: 'npk_test' });
await expect(client.getUsage({ from: 'last week' })).rejects.toBeInstanceOf(ValidationError);
expect(fetch).not.toHaveBeenCalled();
});
});
85 changes: 85 additions & 0 deletions types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@

gateCheck(params: GateCheckApiParams): Promise<GateCheckApiResponse>;

/** Talk to a hosted agent: one chat turn run as the agent, under the delegation you signed for it. */
agentChat(params: AgentChatParams): Promise<AgentChatFinal>;

/** Your credits and spend: the balance, recent events, and spend by line and by UTC day. */
getUsage(options?: UsageOptions): Promise<UsageResult | null>;

createGatePrivateAuth(params: {
address: string;
wallet?: WalletLike;
Expand Down Expand Up @@ -712,6 +718,85 @@
allProofs: any[];
}

export interface AgentChatMessage {
role: 'user' | 'assistant';
content: string;
}

export interface AgentChatParams {
/** The conversation; the last message is from the user. */
messages: AgentChatMessage[];
/** Run as this agent; omit to run as yourself. */
agentId?: string;
/** The agent's account, when two of your agents share an agentId. */
agentWallet?: string;
/** Context from your app, passed to the agent as data. */
systemPrompt?: string;
/** 'default' (the default) or 'advanced'; with `provider`, that provider's model id. */
model?: string;
/** Run on your own model key instead of hosted inference. */
provider?: string;
providerSecretProofQHash?: string;
responseMode?: 'agent' | 'plan';
turnId?: string;
/** Every streamed event as it arrives: turn.start, answer.delta, tool.start, tool.result, final, … */
onEvent?: (type: string, data: Record<string, unknown>) => void;
signal?: AbortSignal;
}

/** A record the agent read; anyone can open it at proofable.me/proof/<qHash>. */
export interface AgentChatCitation {
qHash: string;
title: string | null;
facet: string | null;
visibility: 'public' | 'private' | null;
}

/** The turn's `final` event. */
export interface AgentChatFinal {
turnId: string;
outcome: 'succeeded' | 'failed' | 'blocked' | 'canceled' | 'incomplete';
answer: string;
citations: AgentChatCitation[];
agent?: { agentId: string; agentWallet: string; identityQHash: string | null; delegationQHash: string | null };
credits?: { spent: number };
usage: Record<string, number> | null;
model: string | null;
provider: string | null;
toolActivity: Array<Record<string, unknown>> | null;
error?: { code: string; message: string; retryable: boolean };
[key: string]: unknown;
}

export interface UsageOptions {
/** First UTC day of the period, YYYY-MM-DD. */
from?: string;
/** Last UTC day of the period, YYYY-MM-DD. */
to?: string;
appId?: string;
limit?: number;
}

export interface UsageResult {
orgWallet: string;
profileId: string | null;
credits: { remaining?: number; allowance?: number; [key: string]: unknown } | null;
usage: {
limit: number;
from?: string;
to?: string;
events: Array<Record<string, unknown>>;
summary: {
count: number;
requiredCredits: number;
debitedCredits: number;
byMode: Record<string, number>;
byStatus: Record<string, number>;
debitedByDay: Record<string, number>;
};
};
}

export interface GateCheckApiParams {
address?: string;
/** Subject account. Bare address, CAIP-10, or did:pkh. New integrations use `{ accountId }`. */
Expand Down
Loading