From 1bfa4a4284ab902c50c99cd1508459f700770be1 Mon Sep 17 00:00:00 2001 From: chrisjleal <184772742+chrisjleal@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:12:54 -0600 Subject: [PATCH] client.agentChat talks to a hosted agent from anywhere, and client.getUsage reads your spend agentChat posts one turn to /api/v1/agent/chat/turn as the named agent, streams its events to onEvent and resolves with the final event: the answer, the records the agent read as citations, and the credits it spent. A refusal before the stream is an ApiError with its code. getUsage reads the balance and spend by line and by UTC day for any period. The README's first screen shows the call. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ README.md | 24 ++++++++ client.js | 126 ++++++++++++++++++++++++++++++++++++++++ test/agent-chat.test.js | 96 ++++++++++++++++++++++++++++++ types.d.ts | 85 +++++++++++++++++++++++++++ 5 files changed, 336 insertions(+) create mode 100644 test/agent-chat.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d9af85..4462bd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4f4dfb7..437f925 100644 --- a/README.md +++ b/README.md @@ -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 `, replacing 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. @@ -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 diff --git a/client.js b/client.js index 00da9e0..4167397 100644 --- a/client.js +++ b/client.js @@ -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(); @@ -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/), `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} 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}`; diff --git a/test/agent-chat.test.js b/test/agent-chat.test.js new file mode 100644 index 0000000..1519ee6 --- /dev/null +++ b/test/agent-chat.test.js @@ -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(); + }); +}); diff --git a/types.d.ts b/types.d.ts index 96adeec..7dc2164 100644 --- a/types.d.ts +++ b/types.d.ts @@ -42,6 +42,12 @@ gateCheck(params: GateCheckApiParams): Promise; + /** Talk to a hosted agent: one chat turn run as the agent, under the delegation you signed for it. */ + agentChat(params: AgentChatParams): Promise; + + /** Your credits and spend: the balance, recent events, and spend by line and by UTC day. */ + getUsage(options?: UsageOptions): Promise; + createGatePrivateAuth(params: { address: string; wallet?: WalletLike; @@ -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) => void; + signal?: AbortSignal; + } + + /** A record the agent read; anyone can open it at proofable.me/proof/. */ + 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 | null; + model: string | null; + provider: string | null; + toolActivity: Array> | 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>; + summary: { + count: number; + requiredCredits: number; + debitedCredits: number; + byMode: Record; + byStatus: Record; + debitedByDay: Record; + }; + }; + } + export interface GateCheckApiParams { address?: string; /** Subject account. Bare address, CAIP-10, or did:pkh. New integrations use `{ accountId }`. */