@promptev/client 1.0.0 → 1.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.
@@ -305,8 +305,8 @@ class PromptevClient {
305
305
  * @param agentSlug The agent's slug, as shown on its page in Promptev. Only
306
306
  * PUBLISHED agents in the key's own project can be reached; anything else
307
307
  * answers 404 ({@link NotFoundError}).
308
- * @param message What to say. Required, non-empty after trimming, at most
309
- * {@link MAX_MESSAGE_CHARS} characters.
308
+ * @param message What to say. Non-empty after trimming unless
309
+ * `options.files` are sent, at most {@link MAX_MESSAGE_CHARS} characters.
310
310
  *
311
311
  * @example Stream the answer
312
312
  * ```ts
@@ -333,10 +333,11 @@ class PromptevClient {
333
333
  throw new ValidationError("Name the agent to talk to.", undefined, undefined, "agentSlug");
334
334
  }
335
335
  const text = String(message ?? "").trim();
336
- if (!text) {
336
+ const files = options?.files ?? [];
337
+ if (!text && files.length === 0) {
337
338
  // The server answers 422 for this; refusing here saves the round trip
338
339
  // AND the api_call the workspace would still have been metered for.
339
- throw new ValidationError("Say something to the agent.", undefined, undefined, "message");
340
+ throw new ValidationError("Say something to the agent, or attach a file.", undefined, undefined, "message");
340
341
  }
341
342
  if (text.length > exports.MAX_MESSAGE_CHARS) {
342
343
  throw new ValidationError(`A message can be at most ${exports.MAX_MESSAGE_CHARS} characters.`, undefined, undefined, "message");
@@ -350,16 +351,16 @@ class PromptevClient {
350
351
  obo.groups = options.onBehalfOf.groups;
351
352
  payload.on_behalf_of = obo;
352
353
  }
354
+ if (options?.model != null)
355
+ payload.model = options.model;
356
+ if (options?.toolInputs != null)
357
+ payload.tool_inputs = options.toolInputs;
353
358
  const url = `${this.baseUrl}/api/v1/agents/${encodeURIComponent(slug)}/chat`;
354
359
  // Deliberately NOT fetchWithRetry: a turn is not idempotent — a retried
355
360
  // POST could run the agent, and its tools, a second time.
356
361
  const resp = await this.fetchOnce(url, {
357
362
  method: "POST",
358
- headers: this.authHeaders({
359
- "Content-Type": "application/json",
360
- Accept: "text/event-stream",
361
- }),
362
- body: JSON.stringify(payload),
363
+ ...chatBody(payload, files, (extra) => this.authHeaders(extra)),
363
364
  signal: options?.signal,
364
365
  });
365
366
  if (resp.status >= 400) {
@@ -532,6 +533,33 @@ class PromptevClient {
532
533
  }
533
534
  }
534
535
  exports.PromptevClient = PromptevClient;
536
+ /**
537
+ * The body of a chat call: JSON when there are no files; MULTIPART when there
538
+ * are, the JSON in a `payload` part and each file in a `files` part. Count and
539
+ * size are judged here, before the wire.
540
+ */
541
+ function chatBody(payload, files, headers) {
542
+ if (files.length === 0) {
543
+ return {
544
+ headers: headers({ "Content-Type": "application/json", Accept: "text/event-stream" }),
545
+ body: JSON.stringify(payload),
546
+ };
547
+ }
548
+ if (files.length > exports.MAX_FILES_PER_RUN) {
549
+ throw new ValidationError(`At most ${exports.MAX_FILES_PER_RUN} files can be sent with one call.`, undefined, undefined, "files");
550
+ }
551
+ const form = new FormData();
552
+ form.append("payload", JSON.stringify(payload));
553
+ for (const file of files) {
554
+ const [name, blob] = toBlob(file);
555
+ if (blob.size > exports.MAX_UPLOAD_BYTES) {
556
+ throw new ValidationError(`${name}: larger than ${exports.MAX_UPLOAD_BYTES / (1024 * 1024)} MB — add bigger files to the agent's knowledge instead.`, undefined, undefined, "files");
557
+ }
558
+ form.append("files", blob, name);
559
+ }
560
+ // NO Content-Type — fetch sets the multipart boundary itself.
561
+ return { headers: headers({ Accept: "text/event-stream" }), body: form };
562
+ }
535
563
  /** One upload input, as a named Blob — the only shape `FormData` accepts. */
536
564
  function toBlob(file) {
537
565
  if (typeof Blob !== "undefined" && file instanceof Blob) {
@@ -72,6 +72,34 @@ export interface OnBehalfOf {
72
72
  /** The end user's groups, as your system knows them. Omit for the key's full grant. */
73
73
  groups?: string[];
74
74
  }
75
+ /** A model of a provider the workspace has connected, by the ids its model list shows. */
76
+ export interface ModelChoice {
77
+ provider: string;
78
+ model: string;
79
+ }
80
+ /**
81
+ * What one call overrides on one of the agent's tools, by where each value
82
+ * goes. Only what the tool already has in that place can be overridden; any
83
+ * other name answers 400, and nothing is stored: the next call runs the agent
84
+ * as it was published.
85
+ *
86
+ * - An HTTP tool has `headers`, `query`, `body` and `path` (a `{{name}}` in
87
+ * its URL). A GET or DELETE has no body: all its parameters are `query`.
88
+ * - An MCP server has `headers` (sent to the server) and `arguments` (of its
89
+ * functions; a value reaches every function that takes that argument).
90
+ *
91
+ * A value the model would have filled is fixed for the call: the model neither
92
+ * sees nor changes it, and it shows wherever tool arguments show (the
93
+ * `tool_call` event, approvals). A header, or a value the tool was saved with,
94
+ * never appears in the stream. Put secrets in `headers`.
95
+ */
96
+ export interface ToolInput {
97
+ headers?: Record<string, string>;
98
+ query?: Record<string, unknown>;
99
+ body?: Record<string, unknown>;
100
+ path?: Record<string, unknown>;
101
+ arguments?: Record<string, unknown>;
102
+ }
75
103
  export interface ChatOptions {
76
104
  /**
77
105
  * Continue an existing thread. Read it off the first `conversation` event
@@ -89,6 +117,26 @@ export interface ChatOptions {
89
117
  conversationId?: string;
90
118
  /** Act for one of your end users — see {@link OnBehalfOf}. */
91
119
  onBehalfOf?: OnBehalfOf;
120
+ /**
121
+ * Run this one call on another model. The workspace's own provider key is
122
+ * used; a model it cannot call answers 400 before the turn starts.
123
+ */
124
+ model?: ModelChoice;
125
+ /**
126
+ * Override what the agent's tools are configured with, for this one call,
127
+ * keyed by tool id (shown on the tool's page) — see {@link ToolInput}.
128
+ */
129
+ toolInputs?: Record<string, ToolInput>;
130
+ /**
131
+ * Files for the agent to read on this turn, sent in the same call: at most
132
+ * {@link MAX_FILES_PER_RUN}, each at most 25 MB. Accepted only when the
133
+ * agent's "Chat attachments" is on (409 otherwise), and only the types the
134
+ * promptev chat takes (415 otherwise). An image is shown to a model that can
135
+ * see, and read into text for one that cannot. A document is read before
136
+ * the answer starts, so a long scan makes a slower first event. With files,
137
+ * `message` may be empty.
138
+ */
139
+ files?: UploadInput[];
92
140
  /** Abort the turn from your own signal (in addition to the client timeout). */
93
141
  signal?: AbortSignal;
94
142
  }
@@ -413,8 +461,8 @@ export declare class PromptevClient {
413
461
  * @param agentSlug The agent's slug, as shown on its page in Promptev. Only
414
462
  * PUBLISHED agents in the key's own project can be reached; anything else
415
463
  * answers 404 ({@link NotFoundError}).
416
- * @param message What to say. Required, non-empty after trimming, at most
417
- * {@link MAX_MESSAGE_CHARS} characters.
464
+ * @param message What to say. Non-empty after trimming unless
465
+ * `options.files` are sent, at most {@link MAX_MESSAGE_CHARS} characters.
418
466
  *
419
467
  * @example Stream the answer
420
468
  * ```ts
package/dist/esm/index.js CHANGED
@@ -294,8 +294,8 @@ export class PromptevClient {
294
294
  * @param agentSlug The agent's slug, as shown on its page in Promptev. Only
295
295
  * PUBLISHED agents in the key's own project can be reached; anything else
296
296
  * answers 404 ({@link NotFoundError}).
297
- * @param message What to say. Required, non-empty after trimming, at most
298
- * {@link MAX_MESSAGE_CHARS} characters.
297
+ * @param message What to say. Non-empty after trimming unless
298
+ * `options.files` are sent, at most {@link MAX_MESSAGE_CHARS} characters.
299
299
  *
300
300
  * @example Stream the answer
301
301
  * ```ts
@@ -322,10 +322,11 @@ export class PromptevClient {
322
322
  throw new ValidationError("Name the agent to talk to.", undefined, undefined, "agentSlug");
323
323
  }
324
324
  const text = String(message ?? "").trim();
325
- if (!text) {
325
+ const files = options?.files ?? [];
326
+ if (!text && files.length === 0) {
326
327
  // The server answers 422 for this; refusing here saves the round trip
327
328
  // AND the api_call the workspace would still have been metered for.
328
- throw new ValidationError("Say something to the agent.", undefined, undefined, "message");
329
+ throw new ValidationError("Say something to the agent, or attach a file.", undefined, undefined, "message");
329
330
  }
330
331
  if (text.length > MAX_MESSAGE_CHARS) {
331
332
  throw new ValidationError(`A message can be at most ${MAX_MESSAGE_CHARS} characters.`, undefined, undefined, "message");
@@ -339,16 +340,16 @@ export class PromptevClient {
339
340
  obo.groups = options.onBehalfOf.groups;
340
341
  payload.on_behalf_of = obo;
341
342
  }
343
+ if (options?.model != null)
344
+ payload.model = options.model;
345
+ if (options?.toolInputs != null)
346
+ payload.tool_inputs = options.toolInputs;
342
347
  const url = `${this.baseUrl}/api/v1/agents/${encodeURIComponent(slug)}/chat`;
343
348
  // Deliberately NOT fetchWithRetry: a turn is not idempotent — a retried
344
349
  // POST could run the agent, and its tools, a second time.
345
350
  const resp = await this.fetchOnce(url, {
346
351
  method: "POST",
347
- headers: this.authHeaders({
348
- "Content-Type": "application/json",
349
- Accept: "text/event-stream",
350
- }),
351
- body: JSON.stringify(payload),
352
+ ...chatBody(payload, files, (extra) => this.authHeaders(extra)),
352
353
  signal: options?.signal,
353
354
  });
354
355
  if (resp.status >= 400) {
@@ -520,6 +521,33 @@ export class PromptevClient {
520
521
  return JSON.parse(text);
521
522
  }
522
523
  }
524
+ /**
525
+ * The body of a chat call: JSON when there are no files; MULTIPART when there
526
+ * are, the JSON in a `payload` part and each file in a `files` part. Count and
527
+ * size are judged here, before the wire.
528
+ */
529
+ function chatBody(payload, files, headers) {
530
+ if (files.length === 0) {
531
+ return {
532
+ headers: headers({ "Content-Type": "application/json", Accept: "text/event-stream" }),
533
+ body: JSON.stringify(payload),
534
+ };
535
+ }
536
+ if (files.length > MAX_FILES_PER_RUN) {
537
+ throw new ValidationError(`At most ${MAX_FILES_PER_RUN} files can be sent with one call.`, undefined, undefined, "files");
538
+ }
539
+ const form = new FormData();
540
+ form.append("payload", JSON.stringify(payload));
541
+ for (const file of files) {
542
+ const [name, blob] = toBlob(file);
543
+ if (blob.size > MAX_UPLOAD_BYTES) {
544
+ throw new ValidationError(`${name}: larger than ${MAX_UPLOAD_BYTES / (1024 * 1024)} MB — add bigger files to the agent's knowledge instead.`, undefined, undefined, "files");
545
+ }
546
+ form.append("files", blob, name);
547
+ }
548
+ // NO Content-Type — fetch sets the multipart boundary itself.
549
+ return { headers: headers({ Accept: "text/event-stream" }), body: form };
550
+ }
523
551
  /** One upload input, as a named Blob — the only shape `FormData` accepts. */
524
552
  function toBlob(file) {
525
553
  if (typeof Blob !== "undefined" && file instanceof Blob) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptev/client",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "JavaScript/TypeScript client for Promptev — stream your AI agents and upload documents with a workspace API key",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.cjs",
package/readme.md CHANGED
@@ -76,6 +76,8 @@ client.chat(agentSlug, message, options?): AsyncGenerator<AgentEvent>
76
76
  | `message` | `string` | What to say. Required, non-empty after trimming, at most 20,000 characters. |
77
77
  | `options.conversationId` | `string?` | Continue a thread. Take it from the first `conversation` event. |
78
78
  | `options.onBehalfOf` | `object?` | Act for one of your end users — see [On behalf of your users](#on-behalf-of-your-users). |
79
+ | `options.model` | `{ provider, model }?` | Run this one call on another model. See [Changing the model or a tool's values for one call](#changing-the-model-or-a-tools-values-for-one-call). |
80
+ | `options.toolInputs` | `Record<toolId, ToolInput>?` | Override what the agent's tools are set up with, for this one call. Same section. |
79
81
  | `options.signal` | `AbortSignal?` | Cancel the turn. |
80
82
 
81
83
  Returns an async generator of [events](#events). It ends after `done`, or after `error`.
@@ -103,6 +105,82 @@ Passing `conversationId` is worth doing for a second reason: a tool set to **Ask
103
105
 
104
106
  A thread belongs to the agent, to this API, **and** to the `onBehalfOf.subject` it was opened under. Keep the subject the same across a conversation's turns, and never reuse an id from the app, the portal or an embed — any mismatch answers `404`, not a fresh thread.
105
107
 
108
+ ## Sending files with a message
109
+
110
+ Pass `files` and the agent reads them on that turn, in the same call. This is the paperclip of the promptev chat: the agent takes files only when **Chat attachments** is switched on in its builder, and only the types that chat takes.
111
+
112
+ ```ts
113
+ import { readFile } from "node:fs/promises";
114
+
115
+ for await (const ev of client.chat("support-agent", "What is the total on this receipt?", {
116
+ files: [{ name: "receipt.png", content: await readFile("receipt.png") }], // or a File / Blob
117
+ })) {
118
+ if (ev.type === "token") process.stdout.write(ev.content);
119
+ }
120
+ ```
121
+
122
+ Up to **five files per call**, 25 MB each. An image is shown to a model that can see, and read into text for one that cannot. A document is read before the answer starts, so a long scan makes a slower first event. A call may carry files and an empty message.
123
+
124
+ | Status | When |
125
+ |---|---|
126
+ | 409 | The agent's Chat attachments is off |
127
+ | 415 | A file type the agent cannot take |
128
+ | 413 | The workspace is out of storage |
129
+
130
+ ## Changing the model or a tool's values for one call
131
+
132
+ Both options are optional. Leave them out and the agent runs exactly as it was published. Nothing a call sends is stored: the next call runs the published agent again.
133
+
134
+ ### `model`
135
+
136
+ ```ts
137
+ for await (const ev of client.chat("support-agent", question, {
138
+ model: { provider: "openai", model: "gpt-5-mini" },
139
+ })) { /* … */ }
140
+ ```
141
+
142
+ `provider` and `model` are the ids shown in your workspace's model list. The workspace's own provider key is used; a call never carries a key. The model runs on its default settings, because the agent's saved settings (temperature and the like) were tuned for its own model.
143
+
144
+ The call answers `400` (a `ValidationError`, with `field` set) before the model is called when the workspace has no such provider, the provider has no key, the model is not in its list or has been retired, or the model cannot call tools and the agent has tools.
145
+
146
+ ### `toolInputs`
147
+
148
+ Override what a tool is set up with, keyed by the tool's **id** (shown on the tool's page in promptev, with a copy button). Each value goes under the place it belongs to:
149
+
150
+ ```ts
151
+ for await (const ev of client.chat("support-agent", "How many open orders does ACME have?", {
152
+ toolInputs: {
153
+ "0b6f2c1e-…": { // an HTTP tool
154
+ headers: { Authorization: `Bearer ${endUserToken}` },
155
+ query: { region: "us" },
156
+ body: { account_id: "acme" },
157
+ path: { customer: "acme" }, // a {{customer}} in the tool's URL
158
+ },
159
+ "7d41a9c3-…": { // an MCP server
160
+ headers: { Authorization: `Bearer ${endUserToken}` },
161
+ arguments: { project_id: "p_123" },
162
+ },
163
+ },
164
+ })) { /* … */ }
165
+ ```
166
+
167
+ | Tool | Place | What it is |
168
+ |---|---|---|
169
+ | HTTP | `headers` | A header the tool was saved with. Names match in any letter case. |
170
+ | HTTP | `query` | A query parameter: one the tool was saved with, or one the model fills. A `GET` or `DELETE` tool has no body, so all of its parameters are `query`. |
171
+ | HTTP | `body` | A body value (`POST`, `PUT`, `PATCH`): a key of the saved body, or a parameter the model fills. |
172
+ | HTTP | `path` | A `{{name}}` in the tool's URL. |
173
+ | MCP server | `headers` | A header the server was saved with (`Authorization` when it was saved with a token). |
174
+ | MCP server | `arguments` | An argument of the server's functions. The value reaches every function that takes an argument of that name. |
175
+
176
+ The rules:
177
+
178
+ - **Override only.** A name the tool does not have in that place answers `400`, and the message lists what the tool does have there. Nothing extra is ever passed through.
179
+ - **A value the model would have filled is fixed for the call.** The model neither sees that parameter nor can change it, so the answer's wording follows the question while the tool gets your value.
180
+ - **What is visible.** A value the model would have filled shows wherever tool arguments show: the `tool_call` event and approval requests. A header, and any value the tool was saved with, never appears in the stream. **Put secrets in `headers`.**
181
+ - **Limits.** 20 tools per call, 20 names per place, 16 KB per tool.
182
+ - Overriding a value the tool was **saved with** (a header, a fixed query parameter, a key of the saved body) needs a server that supports it. One that does not answers `400` saying so; it never runs the call without your value.
183
+
106
184
  ## Events
107
185
 
108
186
  Every frame the server can send, typed. Narrow on `event.type`.