@blendification/results 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ For licensees, who run research for client companies under one billing account.
6
+
7
+ - `forCompany(companyId)` returns a client that works in that company. It
8
+ sends the `x-blendification-company` header on every call, so one server can
9
+ serve several companies at once. The server refuses a call for a company you
10
+ cannot act in rather than running it in your own. `createResultsClient`
11
+ takes the same thing as a `companyId` option.
12
+ - `createClientCompany(name)` opens a client company under your billing account
13
+ and returns its `companyId`.
14
+ - `billing.overview()`, `billing.usage()` and `billing.rates()` return the
15
+ account (plan, cycle, balance and companies), the credits spent per company
16
+ and activity with AI usage split by service, and the price list. `usage`
17
+ takes a company, a cycle (`'all'` for all time) or a `from`/`to` window, and
18
+ a `Date` works as a bound.
19
+ - Types for all of the above, pinned to what the server sends by a drift test.
20
+ - `COMPANY_HEADER` is exported.
21
+
22
+ Needs the server deployed with it. An older server answers these tools as
23
+ unknown.
24
+
25
+ ## 0.1.1
26
+
27
+ - `TrackedConversation`, the type of what `conversations()` yields, now has the fields the server sends: `conversationId`, `respondentName` and `respondentEmail`. 0.1.0 declared `id`, `name` and `email`, which were always undefined. The type also gains `abandoned`, `templateId` and the conversation's dates. It drops the catch-all index signature, so a misspelt field no longer compiles.
28
+ - `conversationPage()` returns a typed `ConversationPage`, and `status` accepts one status or a list.
29
+ - The README named the same wrong fields; it now names the real ones.
30
+
3
31
  ## 0.1.0
4
32
 
5
33
  First release.
package/README.md CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  Read Blendification results from your own page or server: how a survey wave is
4
4
  going, who has finished, transcripts, results-group summaries and full exports.
5
- The package also verifies the webhooks Blendification sends when someone starts
6
- or finishes a conversation.
5
+ A licensee can also open client companies, work in each, and see what the
6
+ research cost. The package also verifies the webhooks Blendification sends when
7
+ someone starts or finishes a conversation.
7
8
 
8
9
  It talks to the same MCP endpoint an AI agent uses, so a page sees what its
9
10
  owner's agent sees, through the same tenant checks.
@@ -78,13 +79,65 @@ const results = createResultsClient({
78
79
  | Call | What it returns |
79
80
  | --- | --- |
80
81
  | `progress({ templateId?, inviteGroupName?, since? })` | Status counts, completion rate, and splits by invite group or template, read to the end and added up |
81
- | `conversations({ templateId?, inviteGroupName?, status?, since? })` | An async iterator over every conversation: id, status, name, email, invite group, link |
82
+ | `conversations({ templateId?, inviteGroupName?, status?, since? })` | An async iterator over every conversation: `conversationId`, `status`, `abandoned`, `respondentName`, `respondentEmail`, `inviteGroup`, `link`, and its dates (`startedAt`, `completedAt`) |
82
83
  | `transcript(conversationId, { resultsGroupId })` | Every message, oldest first |
83
84
  | `summary(resultsGroupId)` | A results group's name, counts, topics and reports |
84
85
  | `exportGroup(resultsGroupId, { format: 'csv' })` | Starts an export, waits for it, and returns download links plus the first page |
85
86
  | `watchProgress(args, { onUpdate, intervalMs })` | Polls `progress` and calls `onUpdate` when the counts change. Returns `stop()` |
87
+ | `billing.overview()`, `billing.usage({ companyId?, cycleId?, from?, to? })`, `billing.rates()` | The account, credits spent per company and activity, and the price list (below) |
88
+ | `createClientCompany(name)`, `forCompany(companyId)` | Open a client company, and a client that works in it (below) |
86
89
  | `call(tool, args)` | Any MCP tool the token may use |
87
90
 
91
+ ## Client companies and what they cost
92
+
93
+ A licensee runs research for its own customers, each in a client company of its
94
+ own. The client companies sit under the licensee's billing account, and their
95
+ usage rolls up to it. A licensee's server can open those companies, work in any
96
+ of them, and read what the work cost.
97
+
98
+ ```js
99
+ const results = createResultsClient({ mcpUrl, auth: clientCredentials({ clientId, clientSecret, scope: 'mcp/read mcp/write' }) });
100
+
101
+ const acme = await results.createClientCompany('Acme Health'); // { companyId, name, billingAccountId }
102
+ const forAcme = results.forCompany(acme.companyId); // every call from forAcme works in Acme
103
+ const p = await forAcme.progress({ templateId: '<template id>' });
104
+
105
+ const cycle = await results.billing.usage(); // this cycle, every company
106
+ const acmeCost = await results.billing.usage({ companyId: acme.companyId }); // one company
107
+ const november = await results.billing.usage({ from: '2026-11-01', to: '2026-11-30' });
108
+ const account = await results.billing.overview(); // plan, cycle dates, balance, the companies on the account
109
+ const prices = await results.billing.rates(); // credits per interview, analysis, deck, and the AI rates
110
+ ```
111
+
112
+ `forCompany(id)` sends the `x-blendification-company` header on each call. That
113
+ lets one server work for several companies at once. The server checks every
114
+ call, and refuses one for a company you cannot act in instead of running it in
115
+ your own. `createResultsClient({ ..., companyId })` does the same for a client
116
+ that only ever works in one company.
117
+
118
+ Spend is in credits.
119
+
120
+ - `byCompany` gives each company's credits per activity: interviews started
121
+ and completed, analysis reports, presentations, mixed-data runs and test
122
+ drives.
123
+ - `aiByService` splits AI model usage by what made the calls: the AI
124
+ interviewer, Ask Fusion, analysis or the knowledge graph.
125
+ - Credits granted, expired or adjusted are listed apart in `movements` and are
126
+ not spend.
127
+ - To turn credits into dollars, use the plan in `billing.overview()`. The
128
+ included credits come with `monthlyPriceUsd`, and the rest bill at
129
+ `overageRateUsdPerCredit`.
130
+
131
+ Who can do what:
132
+
133
+ - **Opening companies** needs a manager role on the account (Admin or Client
134
+ Manager) and write access.
135
+ - **Billing** is for the account's Owner and Admins, the people who see Billing
136
+ in the app.
137
+ - **A server's credentials** carry a role when Blendification issues them, so
138
+ ask for Admin if it should do both.
139
+ - **An Admin of a client company** sees only their own company's spend.
140
+
88
141
  ## Webhooks
89
142
 
90
143
  Register a webhook over MCP (`webhook_create`) to have Blendification POST to
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blendification/results",
3
- "version": "0.1.0",
4
- "description": "Read Blendification results (survey progress, conversations, transcripts, exports) from your own page or server, signed in with Blendification's OAuth, and verify the webhooks it sends.",
3
+ "version": "0.2.0",
4
+ "description": "Read Blendification results (survey progress, conversations, transcripts, exports) from your own page or server, signed in with Blendification's OAuth, open client companies and see what research costs, and verify the webhooks it sends.",
5
5
  "license": "MIT",
6
6
  "author": "Blendification",
7
7
  "homepage": "https://github.com/blendification/blendification-platform/tree/main/packages/results-sdk#readme",
@@ -19,7 +19,8 @@
19
19
  "survey",
20
20
  "mcp",
21
21
  "oauth",
22
- "webhooks"
22
+ "webhooks",
23
+ "billing"
23
24
  ],
24
25
  "type": "module",
25
26
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -68,14 +68,31 @@ export function mergeProgress(total, page) {
68
68
 
69
69
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
70
70
 
71
+ /** The request header that names the company one call works in. */
72
+ export const COMPANY_HEADER = 'x-blendification-company';
73
+
74
+ /** A from/to bound as the server takes it: a Date becomes an ISO time, a string passes through. */
75
+ function timeArg(value) {
76
+ if (value === undefined || value === null || value === '') return undefined;
77
+ if (value instanceof Date) {
78
+ if (Number.isNaN(value.getTime())) throw new ResultsError('An invalid Date was passed as a usage window bound.');
79
+ return value.toISOString();
80
+ }
81
+ return String(value);
82
+ }
83
+
71
84
  /**
72
85
  * @param {object} opts
73
86
  * @param {string} opts.mcpUrl - the MCP endpoint, e.g. https://<host>/mcp
74
87
  * @param {object} opts.auth - pkceSignIn(...), clientCredentials(...), staticToken(...), or { getAccessToken }
75
88
  * @param {Function} [opts.fetch]
89
+ * @param {string} [opts.companyId] - work in this company on every call (a client company under your
90
+ * billing account); sent as the x-blendification-company header. forCompany() makes one per company.
76
91
  */
77
92
  export function createResultsClient(opts = {}) {
78
93
  const { mcpUrl, auth } = opts;
94
+ const companyId = opts.companyId === undefined || opts.companyId === null ? null : String(opts.companyId).trim();
95
+ if (companyId === '') throw new ResultsError('companyId is empty. Pass a company id, or leave it out to work in your own company.');
79
96
  if (!mcpUrl) throw new ResultsError('createResultsClient needs mcpUrl (the /mcp address your connection uses).');
80
97
  try { new URL(mcpUrl); } catch { throw new ResultsError(`mcpUrl is not a URL: ${mcpUrl}`); }
81
98
  if (!auth || typeof auth.getAccessToken !== 'function') throw new ResultsError('createResultsClient needs auth: pkceSignIn(), clientCredentials() or staticToken().');
@@ -93,6 +110,7 @@ export function createResultsClient(opts = {}) {
93
110
  accept: 'application/json, text/event-stream',
94
111
  'mcp-protocol-version': PROTOCOL_VERSION,
95
112
  authorization: `Bearer ${token}`,
113
+ ...(companyId ? { [COMPANY_HEADER]: companyId } : {}),
96
114
  },
97
115
  body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcId, method, params }),
98
116
  });
@@ -223,9 +241,41 @@ export function createResultsClient(opts = {}) {
223
241
  return () => { stopped = true; };
224
242
  }
225
243
 
244
+ /**
245
+ * The same connection, working in another company: every call it makes names
246
+ * that company, and the server refuses the call if you cannot act there. The
247
+ * way for one backend to serve several client companies at once.
248
+ */
249
+ function forCompany(id) {
250
+ if (id === undefined || id === null || !String(id).trim()) throw new ResultsError('forCompany needs a company id.');
251
+ return createResultsClient({ ...opts, companyId: String(id).trim() });
252
+ }
253
+
254
+ /** Open a client company under your billing account; returns its companyId. Needs a manager role and write access. */
255
+ function createClientCompany(nameOrOpts) {
256
+ const name = typeof nameOrOpts === 'string' ? nameOrOpts : nameOrOpts?.name;
257
+ if (typeof name !== 'string' || !name.trim()) throw new ResultsError('createClientCompany needs a name.');
258
+ return call('create_client_company', { name: name.trim() });
259
+ }
260
+
261
+ const billing = {
262
+ /** The plan, the cycle, the balance and the companies on your billing account. */
263
+ overview: ({ limit } = {}) => call('billing_overview', { limit }),
264
+ /** Credits spent per company and activity, for the current cycle unless you name another window. */
265
+ usage: ({ companyId: forId, cycleId, from, to, limit } = {}) => call('billing_usage', {
266
+ companyId: forId, cycleId, from: timeArg(from), to: timeArg(to), limit,
267
+ }),
268
+ /** What each activity costs in credits, and the AI rates. */
269
+ rates: ({ includeModels } = {}) => call('billing_rates', { includeModels }),
270
+ };
271
+
226
272
  return {
227
273
  auth,
228
274
  mcpUrl,
275
+ companyId,
276
+ forCompany,
277
+ createClientCompany,
278
+ billing,
229
279
  call,
230
280
  listTools,
231
281
  progress,
package/src/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Webhook verification is in '@blendification/results/webhooks' (Node only).
13
13
  */
14
- export { createResultsClient, mergeProgress, ResultsError, PROTOCOL_VERSION } from './client.js';
14
+ export { createResultsClient, mergeProgress, ResultsError, PROTOCOL_VERSION, COMPANY_HEADER } from './client.js';
15
15
  export {
16
16
  pkceSignIn,
17
17
  clientCredentials,
package/types/index.d.ts CHANGED
@@ -106,24 +106,210 @@ export interface ScopeArgs {
106
106
  since?: string;
107
107
  }
108
108
 
109
+ export type ConversationStatus = 'not started' | 'started' | 'complete';
110
+
111
+ /**
112
+ * One conversation, as conversations() yields it and conversationPage() lists
113
+ * it (the list_template_conversations tool). A drift test in
114
+ * services/analysis-engine pins these names to the rows the server builds.
115
+ */
109
116
  export interface TrackedConversation {
110
- id: string;
111
- status: string;
112
- name?: string | null;
113
- email?: string | null;
114
- inviteGroup?: string | null;
115
- link?: string | null;
116
- [key: string]: unknown;
117
+ conversationId: string;
118
+ /** 'not started', 'started' or 'complete'. */
119
+ status: string | null;
120
+ /** Started, then left without completing. */
121
+ abandoned: boolean;
122
+ /** Null for an anonymous conversation. */
123
+ respondentName: string | null;
124
+ /** Lower-cased. Null for an anonymous conversation. */
125
+ respondentEmail: string | null;
126
+ inviteGroup: string | null;
127
+ templateId: string | null;
128
+ interviewDate: string | null;
129
+ createdAt: string | null;
130
+ startedAt: string | null;
131
+ completedAt: string | null;
132
+ /** The participant's link: the template's hosted page when it names one, else the app's page. */
133
+ link: string | null;
134
+ }
135
+
136
+ /** One page of list_template_conversations. */
137
+ export interface ConversationPage {
138
+ templateId: string | null;
139
+ templateName: string | null;
140
+ inviteGroupName: string | null;
141
+ status: ConversationStatus[] | null;
142
+ since: string | null;
143
+ returned: number;
144
+ conversations: TrackedConversation[];
145
+ nextToken: string | null;
146
+ message: string;
147
+ }
148
+
149
+ /** A plan as billing_overview reports it. Credits are the unit everything is billed in. */
150
+ export interface BillingPlan {
151
+ planId: string | null;
152
+ name: string | null;
153
+ monthlyPriceUsd: number | null;
154
+ includedCreditsPerCycle: number;
155
+ /** What a credit beyond the included ones costs, in USD. */
156
+ overageRateUsdPerCredit: number;
157
+ isSponsored: boolean;
158
+ }
159
+
160
+ /** A company on the billing account. */
161
+ export interface BillingCompany {
162
+ companyId: string;
163
+ name: string | null;
164
+ /** The licensed company that owns the account; the others are its client companies. */
165
+ isLicensee: boolean;
166
+ }
167
+
168
+ /**
169
+ * billing_overview. A drift test in services/analysis-engine pins these names
170
+ * to the object the server builds.
171
+ */
172
+ export interface BillingOverview {
173
+ billingAccountId: string | null;
174
+ name: string | null;
175
+ /** 'account': you see every company on the account; 'company': an Admin of a client company sees only their own. */
176
+ view: 'account' | 'company';
177
+ status: string | null;
178
+ plan: BillingPlan;
179
+ cycle: { id: string | null; start: string | null; end: string | null };
180
+ trialEnd: string | null;
181
+ /** Credits left. */
182
+ balance: number;
183
+ lifetimeGranted: number;
184
+ lifetimeDebited: number;
185
+ overageUnits: number;
186
+ enforcement: string | null;
187
+ licenseeCompanyId: string | null;
188
+ homeCompanyId: string | null;
189
+ /** The company this call worked in: your own, or the one forCompany() / the header named. */
190
+ actingCompanyId: string | null;
191
+ companies: BillingCompany[];
192
+ /** Every company on the account, even when `companies` is cut to the limit. */
193
+ companyCount: number;
194
+ companiesTruncated: boolean;
195
+ unit: 'credits';
196
+ }
197
+
198
+ /** Spend on one kind of activity. `type` is the ledger's key; `label` says it in words. */
199
+ export interface UsageByType {
200
+ type: string;
201
+ label: string;
202
+ count: number;
203
+ spentCredits: number;
204
+ }
205
+
206
+ /** AI spend by the service that made the model calls (the AI interviewer, Ask Fusion, analysis, ...). */
207
+ export interface UsageByService {
208
+ service: string;
209
+ label: string;
210
+ count: number;
211
+ spentCredits: number;
212
+ }
213
+
214
+ /** One company's spend. companyId null is spend tied to no company (account level). */
215
+ export interface CompanyUsage {
216
+ companyId: string | null;
217
+ name: string | null;
218
+ spentCredits: number;
219
+ count: number;
220
+ types: UsageByType[];
221
+ aiByService: UsageByService[];
222
+ }
223
+
224
+ /** Credits added or removed that are not anyone's use: grants, top-ups, expiries, adjustments. Signed. */
225
+ export interface CreditMovement {
226
+ type: string;
227
+ label: string;
228
+ kind: string | null;
229
+ count: number;
230
+ credits: number;
231
+ }
232
+
233
+ /** billing_usage. Only use is spend; movements are reported apart. */
234
+ export interface BillingUsage {
235
+ billingAccountId: string | null;
236
+ view: 'account' | 'company';
237
+ /** The one company reported, or null for every company you can see. */
238
+ companyId: string | null;
239
+ /** Why that company: you asked, you asked for "all", the call worked in it, or the default. */
240
+ companyChosenBy: 'asked' | 'all' | 'acting' | 'default';
241
+ window: { scope: 'cycle' | 'all' | 'range' | null; cycleId: string | null; from: string | null; to: string | null };
242
+ unit: 'credits';
243
+ totals: { spentCredits: number; debitCount: number; addedCredits: number; removedCredits: number };
244
+ byType: UsageByType[];
245
+ aiByService: UsageByService[];
246
+ /** Largest spend first, cut to the limit; the rest are summed in otherCompanies. */
247
+ byCompany: CompanyUsage[];
248
+ otherCompanies: { count: number; spentCredits: number } | null;
249
+ movements: CreditMovement[];
250
+ entryCount: number;
251
+ /** True when the window held more ledger entries than one read covers: narrow it. */
252
+ truncated: boolean;
253
+ }
254
+
255
+ /** Credits per 1,000 input and output tokens, and per unit (a character, an audio second, an image). */
256
+ export interface AiRate {
257
+ inputPer1k: number;
258
+ outputPer1k: number;
259
+ perUnit: number;
260
+ }
261
+
262
+ export interface ModelRate extends AiRate {
263
+ /** provider:model */
264
+ model: string;
265
+ kinds?: Record<string, Partial<AiRate>>;
266
+ }
267
+
268
+ /** billing_rates. The same for every account. */
269
+ export interface BillingRates {
270
+ version: string | null;
271
+ unit: 'credits';
272
+ events: Array<{ type: string; label: string; credits: number }>;
273
+ aiDefault: AiRate | null;
274
+ modelCount: number;
275
+ /** Null unless includeModels was asked for. */
276
+ models: ModelRate[] | null;
277
+ roundingMode: string | null;
278
+ }
279
+
280
+ /** create_client_company's answer. */
281
+ export interface ClientCompany {
282
+ companyId: string;
283
+ name: string;
284
+ billingAccountId: string | null;
285
+ message: string;
286
+ }
287
+
288
+ /** A from/to bound: a Date, or YYYY-MM-DD (a whole day when it ends a window), or an ISO time. */
289
+ export type UsageBound = Date | string;
290
+
291
+ export interface BillingReads {
292
+ overview(opts?: { limit?: number }): Promise<BillingOverview>;
293
+ usage(opts?: { companyId?: string | 'all'; cycleId?: string | 'all'; from?: UsageBound; to?: UsageBound; limit?: number }): Promise<BillingUsage>;
294
+ rates(opts?: { includeModels?: boolean }): Promise<BillingRates>;
117
295
  }
118
296
 
119
297
  export interface ResultsClient {
120
298
  auth: AuthProvider;
121
299
  mcpUrl: string;
300
+ /** The company every call names (x-blendification-company), or null for your own. */
301
+ companyId: string | null;
302
+ /** The same connection working in another company. The server refuses a call in a company you cannot act in. */
303
+ forCompany(companyId: string): ResultsClient;
304
+ /** Open a client company under your billing account. Needs a manager role and write access. */
305
+ createClientCompany(nameOrOpts: string | { name: string }): Promise<ClientCompany>;
306
+ /** What research costs: the account, spend per company and activity, and the price list. */
307
+ billing: BillingReads;
122
308
  call<T = any>(tool: string, args?: Record<string, unknown>): Promise<T>;
123
309
  listTools(): Promise<Array<{ name: string; description: string; inputSchema: object }>>;
124
310
  progress(args: ScopeArgs): Promise<Progress>;
125
- conversationPage(args: ScopeArgs & { status?: string[]; limit?: number; nextToken?: string }): Promise<any>;
126
- conversations(args: ScopeArgs & { status?: string[]; limit?: number }): AsyncGenerator<TrackedConversation>;
311
+ conversationPage(args: ScopeArgs & { status?: ConversationStatus | ConversationStatus[]; limit?: number; nextToken?: string }): Promise<ConversationPage>;
312
+ conversations(args: ScopeArgs & { status?: ConversationStatus | ConversationStatus[]; limit?: number }): AsyncGenerator<TrackedConversation>;
127
313
  transcript(conversationId: string, opts: { resultsGroupId: string; limit?: number }): Promise<{ conversationId: string; messages: any[]; complete: boolean }>;
128
314
  waitForJob(jobId: string, opts?: { intervalMs?: number; timeoutMs?: number; onUpdate?: (status: any) => void }): Promise<any>;
129
315
  exportGroup(resultsGroupId: string, opts?: { format?: 'json' | 'csv'; conversationIds?: string[]; includeMessages?: boolean; includeFeatures?: boolean; waitMs?: number }): Promise<any>;
@@ -133,5 +319,6 @@ export interface ResultsClient {
133
319
  templates(args?: Record<string, unknown>): Promise<any>;
134
320
  }
135
321
 
136
- export declare function createResultsClient(opts: { mcpUrl: string; auth: AuthProvider; fetch?: typeof fetch }): ResultsClient;
322
+ export declare const COMPANY_HEADER: 'x-blendification-company';
323
+ export declare function createResultsClient(opts: { mcpUrl: string; auth: AuthProvider; fetch?: typeof fetch; companyId?: string }): ResultsClient;
137
324
  export declare function mergeProgress(total: Progress | null, page: Record<string, unknown>): Progress;