dyadic 1.8.1 → 1.8.2

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/README.md CHANGED
@@ -1,305 +1,305 @@
1
- # dyadic
2
-
3
- ```sh
4
- npm install dyadic
5
- ```
6
-
7
- The official TypeScript and JavaScript SDK for the [Dyadic API](https://dyadic.app/developers/): Iris with streaming, function calling, images and typed errors. No dependencies; for server-side code on Node 18+, Deno and Bun (keep your key off the browser, see below).
8
-
9
- ```ts
10
- import { Dyadic } from "dyadic";
11
-
12
- const client = new Dyadic({ apiKey: process.env.DYADIC_API_KEY });
13
- console.log(await client.ask("Hello!", { model: "iris" }));
14
- ```
15
-
16
- ## Keep your key on the server
17
-
18
- **A Dyadic API key is a long-lived secret that spends your credits. Never ship it
19
- to a browser**: not in a bundle, page source, local storage or a request your
20
- front end makes, where any visitor or extension can copy it. Use the SDK in
21
- server-side code (Node 18+, Deno, Bun, edge functions) and have your front end
22
- call your own server:
23
-
24
- ```ts
25
- // server.ts: the key stays here
26
- import { Dyadic } from "dyadic";
27
- const client = new Dyadic({ apiKey: process.env.DYADIC_API_KEY });
28
-
29
- export async function POST(request: Request) {
30
- const { question } = await request.json();
31
- // Check your own user's session and limits here before spending credits.
32
- return Response.json({ answer: await client.ask(String(question).slice(0, 4000), { model: "iris" }) });
33
- }
34
- ```
35
-
36
- `request()` only ever sends the key to the API you configured: a relative path
37
- joins `baseURL`, and an absolute URL on any other host, port or scheme (or with
38
- credentials in it) throws `DyadicError` instead of being sent. Redirects are not
39
- followed. Plain `http://` is accepted only for a local or private-network API.
40
-
41
- ## Retries
42
-
43
- GET, HEAD, DELETE and OPTIONS are retried on busy or failing servers. Anything
44
- else (a completion, an upload, a run) may already have run, and been billed,
45
- when it fails, so it is retried only if it provably never started: the
46
- connection was never made, or Dyadic refused it with `rate_limit_exceeded` or
47
- `concurrency_limit`. Every attempt of one call carries the same
48
- `Idempotency-Key`; pass `{ idempotencyKey }` to choose it, or `{ idempotent: true }`
49
- for a call that is safe to send twice.
50
-
51
- ## Runs: your own agent, Dyadic's tools, your functions
52
-
53
- ```ts
54
- const run = await client.runs.run({
55
- agent: { id: "helpdesk", name: "Helpdesk", instructions: "You answer customers of a small shop." },
56
- messages: [{ role: "user", content: "Where is order 1042?" }],
57
- builtin_tools: ["data.web_search"],
58
- functions: {
59
- lookup_order: {
60
- description: "Look up an order by id.",
61
- parameters: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"] },
62
- handler: async ({ order_id }) => ({ order_id, status: "shipped" }),
63
- },
64
- },
65
- end_user: "customer-17",
66
- });
67
- console.log(run.output_text);
68
- ```
69
-
70
- `client.runs.create({ ..., stream: true })` streams the run's events. Leaving a `for await` over any stream early
71
- (`break`, `return`, a throw) ends its request too, so nothing keeps running (or billing) behind it.
72
-
73
- ## Plans: propose, approve, carry out
74
-
75
- With the key's plans switch on, a run can plan before it acts. `planMode: true` investigates read-only and proposes a
76
- plan of Steps; nothing that changes anything runs. Your user reviews it (edit it with `plans.update`), you approve the
77
- revision they saw, and a run with `planId` carries it out step by step. The service checks the approval: a plan that isn't
78
- approved at its current revision doesn't run (`plan_not_runnable`). A run that ends with steps left pauses the plan;
79
- another run with the same `planId` resumes it.
80
-
81
- ```ts
82
- const proposal = await client.runs.create({ agent: "iris", messages: [{ role: "user", content: "Move our blog to a static site." }],
83
- planMode: true, conversation_id: "ticket-88" });
84
- let plan = await client.plans.retrieve(proposal.plans![0].id);
85
- plan = await client.plans.approve(plan.id, { revision: plan.revision });
86
- await client.runs.create({ agent: "iris", messages: [{ role: "user", content: "Go ahead." }], planId: plan.id, conversation_id: "ticket-88" });
87
-
88
- for await (const event of client.plans.stream(plan.id)) console.log(event.event, event.data.step, event.data.status);
89
- ```
90
-
91
- `client.plans` also has `list({ conversation, status, limit, after })`, `create({ title, steps, mode })`,
92
- `update(id, { revision, ... })`, `cancel(id)`, `moveStep(id, key, { status, note, verified_by })` and
93
- `events(id, { after })`. The stream resumes after the last event (Last-Event-ID), ends once the plan is finished, and
94
- honors an AbortSignal. A key sees only its own plans.
95
-
96
- ## Decisions: typed answers, fast and cheap
97
-
98
- Ask one or more questions about some text (or text and images) and get typed answers
99
- back: a probability (`predicate`), one of your values (`choice`) or a rubric level
100
- (`score`). A fraction of a second and a fraction of a cent; billed at cost.
101
-
102
- ```ts
103
- const decision = await client.decisions.create({
104
- input: "I can't log in after resetting my password.",
105
- questions: [
106
- { type: "predicate", name: "urgent", instructions: "The customer is blocked from using the product." },
107
- { type: "choice", name: "team", instructions: "Which team should handle this.", choices: [
108
- { value: "account", description: "Sign-in and account access" },
109
- { value: "billing", description: "Payments and invoices" },
110
- { value: "other", description: "Anything else" },
111
- ] },
112
- ],
113
- });
114
- ```
115
-
116
- ## Connected apps: saving into a person's Workspace
117
-
118
- With the person's approval, an application can save into their Dyadic
119
- Workspace, naming them by the Discord account linked to their Dyadic account.
120
-
121
- ```ts
122
- import { Dyadic, DyadicError } from "dyadic";
123
-
124
- const status = await client.connections.discord(discordId);
125
- if (!status.authorized) console.log("Allow this app:", status.authorize_url); // show the person this link
126
-
127
- try {
128
- const saved = await client.workspace.saves.create({ discord_id: discordId, kind: "message", title: "From #general",
129
- text: "Worth keeping", url: "https://discord.com/channels/1/2/3" });
130
- console.log(saved.url);
131
- } catch (error) {
132
- if (error instanceof DyadicError && error.code === "authorization_required") {
133
- console.log("Allow this app:", (error.body as { authorize_url?: string }).authorize_url);
134
- }
135
- }
136
- ```
137
-
138
- `kind` is `message`, `conversation` (with `messages`, saved as a chat the
139
- person can continue unless `as_chat: false`), `image` or `file` (with `files`:
140
- base64, up to 10 of 8 MB).
141
-
142
- ## Conversations, stored completions, containers, vector stores, skills
143
-
144
- The rest of the OpenAI-compatible API, named as OpenAI's SDK names it:
145
-
146
- ```ts
147
- const conversation = await client.conversations.create({ metadata: { ticket: "88" } });
148
- await client.responses.create({ model: "iris", input: "Hello", conversation: conversation.id });
149
- const items = await client.conversations.items.list(conversation.id, { order: "asc" });
150
-
151
- const { input_tokens } = await client.responses.inputTokens.count({ model: "iris", input: "How long is this?" }); // free
152
- const compacted = await client.responses.compact({ model: "iris", previous_response_id: "resp_..." }); // send compacted.output next
153
-
154
- const stored = await client.chat.completions.list({ metadata: { team: "support" } }); // completions made with store: true
155
- await client.chat.completions.messages.list(stored.data[0].id);
156
-
157
- const container = await client.containers.create({ name: "scratch" }); // code interpreter switch
158
- await client.containers.files.create(container.id, { file: { name: "data.csv", data: csv } });
159
-
160
- const store = await client.vectorStores.create({ name: "docs", file_ids: [file.id] }); // file search switch; no credits
161
- const hits = await client.vectorStores.search(store.id, { query: "refund policy" });
162
-
163
- const skill = await client.skills.create({ files: [{ name: "SKILL.md", data: skillMd }, { name: "scripts/read.py", data: script }] });
164
- ```
165
-
166
- Also `conversations.retrieve/update/delete`, `conversations.items.create/retrieve/delete`, `chat.completions.retrieve/update/delete`,
167
- `containers.files.list/retrieve/delete/content.retrieve`, `vectorStores.files.*` and `vectorStores.fileBatches.*`, and
168
- `skills.versions.*` (a skill is uploaded as its files, each named by its path in the skill, or as one zip).
169
-
170
- ## The Anthropic API and OpenRouter's, on the same key
171
-
172
- `client.anthropic` speaks [the Anthropic API](https://dyadic.app/developers/#anthropic) at `/api/anthropic/v1`
173
- (Claude's request and answer shapes, behaving as Anthropic's do) and `client.openrouter` OpenRouter's API through the
174
- [AI gateway](https://dyadic.app/developers/#openrouter) at `/api/openrouter/v1`, both with this client's key and beside
175
- its base URL:
176
-
177
- ```ts
178
- const message = await client.anthropic.messages.create({ model: "iris", max_tokens: 1024, messages: [{ role: "user", content: "Hello" }] });
179
- console.log(message.content, message.stop_reason, message.usage.output_tokens); // Anthropic's answer
180
-
181
- const stream = await client.anthropic.messages.create({ model: "iris", max_tokens: 1024, stream: true, messages: [{ role: "user", content: "Tell me a story" }] });
182
- for await (const event of stream) if (event.type === "content_block_delta" && event.delta?.type === "text_delta") process.stdout.write(event.delta.text);
183
- console.log(stream.usage); // input and output tokens
184
-
185
- const batch = await client.anthropic.messages.batches.create({ requests: [{ custom_id: "a", params: { model: "iris", max_tokens: 256, messages: [{ role: "user", content: "Hi" }] } }] });
186
- const results = await client.anthropic.messages.batches.results(batch.id); // once processing_status is "ended"
187
-
188
- // OpenRouter's models through the AI gateway (Dyadic turns it on for an app's key on request):
189
- const reply = await client.openrouter.chat.completions.create({ model: "anthropic/claude-haiku-5.5", messages: [{ role: "user", content: "Hello" }] });
190
- const { data } = await client.openrouter.key(); // this key's limits, answered by Dyadic
191
- ```
192
-
193
- Also `anthropic.messages.countTokens` (free), `anthropic.messages.batches.list/retrieve/cancel/delete`,
194
- `anthropic.models.list/retrieve`, `anthropic.files.create/list/retrieve/content/delete`, and `openrouter.completions`,
195
- `openrouter.embeddings`, `openrouter.models.list/endpoints`, `openrouter.generation` and `openrouter.credits`. The
196
- gateway is a platform decision: Dyadic turns it on for an app's key on request, the key's owner can turn it off, and a
197
- key without it gets `403 gateway_not_allowed`. Anthropic's own SDKs and Claude Code work too, with the base URL
198
- `https://dyadic.app/api/anthropic`.
199
-
200
- ## Agent Black Box: a flight recorder for your own agent
201
-
202
- Record what your agent does into Dyadic's tamper-evident Black Box (hash-chained,
203
- signed, verifiable offline). Switch **Black Box: send telemetry** (and **read**) on
204
- for the key first; both are off by default.
205
-
206
- ```ts
207
- import { BlackBoxRecorder, verifyBundle } from "dyadic";
208
-
209
- const recorder = new BlackBoxRecorder(client);
210
- await recorder.run({ title: "Book a room", agent: "planner" }, async (run) => {
211
- run.declare({ allowedTools: ["book_room"], allowedHosts: ["rooms.example.com"] });
212
- await run.toolCall("book_room", { effect: true, hosts: ["rooms.example.com"] }, async (tool) => tool.result(await bookRoom("Orion")));
213
- run.verified("booking", "confirmation ORN-1042 read back from the booking API");
214
- });
215
- await recorder.flush();
216
- ```
217
-
218
- Spans nest through their own methods (`run.agentSpan("researcher", {}, async (sub) => sub.toolCall(...))`); a
219
- remote subagent joins the trace with `recorder.start({ traceparent: span.traceparent() })`. Only metadata is sent
220
- unless `{ captureContent: true }` (secrets redacted first). `instrumentMcpClient(mcpClient, run)` records MCP tool
221
- calls. `client.blackbox.runs.export(id)` returns an evidence bundle; `await verifyBundle(bundle, { [keyId]: publicKey })`
222
- checks it offline with WebCrypto. `client.blackbox.runs.simulate(id, { mode })` never runs anything.
223
-
224
- ### Reading, triaging and managing
225
-
226
- With the key's **read** switch: `client.blackbox.overview()`, `settings()`, `runs.list/retrieve/events({ types })/graph/verify/
227
- checkpoints/otlp/export`, `diff(a, b)`, `findings.list({ status, severity, rule })`, `rules.list()` and
228
- `rules.preview({ rules })` (what detection would raise with other settings; nothing is saved). **Write**: `bookmarks.create(runId,
229
- { seq, note })`, `findings.update(id, { status: "dismissed" })`. **Content**: `runs.content(id)`. **Manage**: `runs.erase(id)`,
230
- `runs.delete(id)` and the sinks below. A run of the key's that was deleted throws `RunDeletedError` (HTTP 410). OTLP protobuf
231
- goes as it is: `client.blackbox.sendOtlp(bytes)`.
232
-
233
- ### Live
234
-
235
- ```ts
236
- const controller = new AbortController();
237
- for await (const { event, id, data } of client.blackbox.runs.stream(runId, { signal: controller.signal })) {
238
- if (event === "event") console.log(id, data.type); // id is the event's sequence number
239
- if (event === "end") console.log(data.status, data.outcome); // the stream stops after end (or deleted)
240
- }
241
- for await (const { event, data } of client.blackbox.watch()) if (event === "run") console.log(data.id, data.status);
242
- ```
243
-
244
- Both connect again by themselves when the service asks or the connection drops (a run's stream resumes after its last
245
- event), and end when you leave the loop or abort the signal. Node 18+ and browsers (fetch and ReadableStream).
246
-
247
- ### Webhooks and collectors
248
-
249
- `client.blackbox.sinks.create({ kind: "webhook", url, events: ["finding.created", "run.failed"], min_severity: "high" })` sends
250
- signed JSON as findings are raised and runs end (keep its `secret`: it is shown once; `sinks.rotate(id)` makes a new one);
251
- `{ kind: "otlp", url, protocol: "http/protobuf", headers }` exports each finished run to an OpenTelemetry collector. Check every
252
- webhook before trusting it, with the body exactly as it arrived:
253
-
254
- ```ts
255
- import { verifyWebhook, WebhookVerificationError } from "dyadic";
256
-
257
- export async function POST(request: Request) {
258
- try {
259
- const message = await verifyWebhook(await request.text(), request.headers, process.env.BLACKBOX_WEBHOOK_SECRET!);
260
- // message.type: finding.created, run.completed, run.failed, ...; skip repeats by the webhook-id header
261
- } catch (error) {
262
- if (error instanceof WebhookVerificationError) return new Response("bad signature", { status: 400 });
263
- throw error;
264
- }
265
- return new Response(null, { status: 204 });
266
- }
267
- ```
268
-
269
- (Standard Webhooks: HMAC-SHA256, several signatures during a rotation, a timestamp within 5 minutes by default.)
270
-
271
- ### Anchors
272
-
273
- When the platform anchors its checkpoint logs (RFC 3161 timestamps over a Merkle root), bundles carry the anchor and
274
- `verifyBundle` checks it: the anchored checkpoint is in the bundle's log at or after the run's last checkpoint, its Merkle
275
- proof leads to the root, and the timestamp token stamps that root (its time is in the check's detail). The authority's
276
- signature on the token is **not** checked here (it needs CMS and certificate work this verifier doesn't do); the report says
277
- so in its warnings. The Python SDK's `verify_bundle` and the bundle's `verify.py` check it.
278
-
279
- ### OpenTelemetry
280
-
281
- If your agent framework already makes OpenTelemetry spans, send them as they are. `BlackBoxSpanExporter` is a
282
- `SpanExporter` for the official SDK (1.x and 2.x; nothing from OpenTelemetry is needed at runtime):
283
-
284
- ```ts
285
- import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
286
- import { BlackBoxSpanExporter } from "dyadic";
287
-
288
- const provider = new BasicTracerProvider({ spanProcessors: [new BatchSpanProcessor(new BlackBoxSpanExporter(client))] });
289
- ```
290
-
291
- Spans following the GenAI semantic conventions (`gen_ai.operation.name`: `chat`, `execute_tool`, `invoke_agent`, ...)
292
- or OpenInference (`openinference.span.kind`) become model calls, tool calls and agents; others are kept as generic
293
- spans; one run per trace. Content attributes (`gen_ai.input.messages`, `gen_ai.tool.call.arguments`, `input.value`,
294
- ...) stay in your process unless `{ captureContent: true }`, and every value is redacted first (secrets, by default;
295
- `redact` replaces it). Large batches are split to fit the service (1,000 spans or 4 MB per request), and a failed
296
- export is reported to the span processor, never thrown. `toOtlpRequests(spans)` gives the OTLP/JSON without sending it.
297
-
298
- ## Tests
299
-
300
- `npm test` runs the SDK's own tests against a mocked fetch or a local stand-in server: nothing reaches Dyadic and nothing
301
- is spent. `npm run test:production` runs the live checks in `live_tests/` against a real API, only with
302
- `DYADIC_LIVE_API_KEY` set (`DYADIC_LIVE_BASE_URL` for another deployment): free listings and token counts, and two
303
- messages of at most 16 tokens. Use a short-lived key with a small credit cap.
304
-
305
- Full guide: https://dyadic.app/developers/
1
+ # dyadic
2
+
3
+ ```sh
4
+ npm install dyadic
5
+ ```
6
+
7
+ The official TypeScript and JavaScript SDK for the [Dyadic API](https://dyadic.app/developers/): Iris with streaming, function calling, images and typed errors. No dependencies; for server-side code on Node 18+, Deno and Bun (keep your key off the browser, see below).
8
+
9
+ ```ts
10
+ import { Dyadic } from "dyadic";
11
+
12
+ const client = new Dyadic({ apiKey: process.env.DYADIC_API_KEY });
13
+ console.log(await client.ask("Hello!", { model: "iris" }));
14
+ ```
15
+
16
+ ## Keep your key on the server
17
+
18
+ **A Dyadic API key is a long-lived secret that spends your credits. Never ship it
19
+ to a browser**: not in a bundle, page source, local storage or a request your
20
+ front end makes, where any visitor or extension can copy it. Use the SDK in
21
+ server-side code (Node 18+, Deno, Bun, edge functions) and have your front end
22
+ call your own server:
23
+
24
+ ```ts
25
+ // server.ts: the key stays here
26
+ import { Dyadic } from "dyadic";
27
+ const client = new Dyadic({ apiKey: process.env.DYADIC_API_KEY });
28
+
29
+ export async function POST(request: Request) {
30
+ const { question } = await request.json();
31
+ // Check your own user's session and limits here before spending credits.
32
+ return Response.json({ answer: await client.ask(String(question).slice(0, 4000), { model: "iris" }) });
33
+ }
34
+ ```
35
+
36
+ `request()` only ever sends the key to the API you configured: a relative path
37
+ joins `baseURL`, and an absolute URL on any other host, port or scheme (or with
38
+ credentials in it) throws `DyadicError` instead of being sent. Redirects are not
39
+ followed. Plain `http://` is accepted only for a local or private-network API.
40
+
41
+ ## Retries
42
+
43
+ GET, HEAD, DELETE and OPTIONS are retried on busy or failing servers. Anything
44
+ else (a completion, an upload, a run) may already have run, and been billed,
45
+ when it fails, so it is retried only if it provably never started: the
46
+ connection was never made, or Dyadic refused it with `rate_limit_exceeded` or
47
+ `concurrency_limit`. Every attempt of one call carries the same
48
+ `Idempotency-Key`; pass `{ idempotencyKey }` to choose it, or `{ idempotent: true }`
49
+ for a call that is safe to send twice.
50
+
51
+ ## Runs: your own agent, Dyadic's tools, your functions
52
+
53
+ ```ts
54
+ const run = await client.runs.run({
55
+ agent: { id: "helpdesk", name: "Helpdesk", instructions: "You answer customers of a small shop." },
56
+ messages: [{ role: "user", content: "Where is order 1042?" }],
57
+ builtin_tools: ["data.web_search"],
58
+ functions: {
59
+ lookup_order: {
60
+ description: "Look up an order by id.",
61
+ parameters: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"] },
62
+ handler: async ({ order_id }) => ({ order_id, status: "shipped" }),
63
+ },
64
+ },
65
+ end_user: "customer-17",
66
+ });
67
+ console.log(run.output_text);
68
+ ```
69
+
70
+ `client.runs.create({ ..., stream: true })` streams the run's events. Leaving a `for await` over any stream early
71
+ (`break`, `return`, a throw) ends its request too, so nothing keeps running (or billing) behind it.
72
+
73
+ ## Plans: propose, approve, carry out
74
+
75
+ With the key's plans switch on, a run can plan before it acts. `planMode: true` investigates read-only and proposes a
76
+ plan of Steps; nothing that changes anything runs. Your user reviews it (edit it with `plans.update`), you approve the
77
+ revision they saw, and a run with `planId` carries it out step by step. The service checks the approval: a plan that isn't
78
+ approved at its current revision doesn't run (`plan_not_runnable`). A run that ends with steps left pauses the plan;
79
+ another run with the same `planId` resumes it.
80
+
81
+ ```ts
82
+ const proposal = await client.runs.create({ agent: "iris", messages: [{ role: "user", content: "Move our blog to a static site." }],
83
+ planMode: true, conversation_id: "ticket-88" });
84
+ let plan = await client.plans.retrieve(proposal.plans![0].id);
85
+ plan = await client.plans.approve(plan.id, { revision: plan.revision });
86
+ await client.runs.create({ agent: "iris", messages: [{ role: "user", content: "Go ahead." }], planId: plan.id, conversation_id: "ticket-88" });
87
+
88
+ for await (const event of client.plans.stream(plan.id)) console.log(event.event, event.data.step, event.data.status);
89
+ ```
90
+
91
+ `client.plans` also has `list({ conversation, status, limit, after })`, `create({ title, steps, mode })`,
92
+ `update(id, { revision, ... })`, `cancel(id)`, `moveStep(id, key, { status, note, verified_by })` and
93
+ `events(id, { after })`. The stream resumes after the last event (Last-Event-ID), ends once the plan is finished, and
94
+ honors an AbortSignal. A key sees only its own plans.
95
+
96
+ ## Decisions: typed answers, fast and cheap
97
+
98
+ Ask one or more questions about some text (or text and images) and get typed answers
99
+ back: a probability (`predicate`), one of your values (`choice`) or a rubric level
100
+ (`score`). A fraction of a second and a fraction of a cent; billed at cost.
101
+
102
+ ```ts
103
+ const decision = await client.decisions.create({
104
+ input: "I can't log in after resetting my password.",
105
+ questions: [
106
+ { type: "predicate", name: "urgent", instructions: "The customer is blocked from using the product." },
107
+ { type: "choice", name: "team", instructions: "Which team should handle this.", choices: [
108
+ { value: "account", description: "Sign-in and account access" },
109
+ { value: "billing", description: "Payments and invoices" },
110
+ { value: "other", description: "Anything else" },
111
+ ] },
112
+ ],
113
+ });
114
+ ```
115
+
116
+ ## Connected apps: saving into a person's Workspace
117
+
118
+ With the person's approval, an application can save into their Dyadic
119
+ Workspace, naming them by the Discord account linked to their Dyadic account.
120
+
121
+ ```ts
122
+ import { Dyadic, DyadicError } from "dyadic";
123
+
124
+ const status = await client.connections.discord(discordId);
125
+ if (!status.authorized) console.log("Allow this app:", status.authorize_url); // show the person this link
126
+
127
+ try {
128
+ const saved = await client.workspace.saves.create({ discord_id: discordId, kind: "message", title: "From #general",
129
+ text: "Worth keeping", url: "https://discord.com/channels/1/2/3" });
130
+ console.log(saved.url);
131
+ } catch (error) {
132
+ if (error instanceof DyadicError && error.code === "authorization_required") {
133
+ console.log("Allow this app:", (error.body as { authorize_url?: string }).authorize_url);
134
+ }
135
+ }
136
+ ```
137
+
138
+ `kind` is `message`, `conversation` (with `messages`, saved as a chat the
139
+ person can continue unless `as_chat: false`), `image` or `file` (with `files`:
140
+ base64, up to 10 of 8 MB).
141
+
142
+ ## Conversations, stored completions, containers, vector stores, skills
143
+
144
+ The rest of the OpenAI-compatible API, named as OpenAI's SDK names it:
145
+
146
+ ```ts
147
+ const conversation = await client.conversations.create({ metadata: { ticket: "88" } });
148
+ await client.responses.create({ model: "iris", input: "Hello", conversation: conversation.id });
149
+ const items = await client.conversations.items.list(conversation.id, { order: "asc" });
150
+
151
+ const { input_tokens } = await client.responses.inputTokens.count({ model: "iris", input: "How long is this?" }); // free
152
+ const compacted = await client.responses.compact({ model: "iris", previous_response_id: "resp_..." }); // send compacted.output next
153
+
154
+ const stored = await client.chat.completions.list({ metadata: { team: "support" } }); // completions made with store: true
155
+ await client.chat.completions.messages.list(stored.data[0].id);
156
+
157
+ const container = await client.containers.create({ name: "scratch" }); // code interpreter switch
158
+ await client.containers.files.create(container.id, { file: { name: "data.csv", data: csv } });
159
+
160
+ const store = await client.vectorStores.create({ name: "docs", file_ids: [file.id] }); // file search switch; no credits
161
+ const hits = await client.vectorStores.search(store.id, { query: "refund policy" });
162
+
163
+ const skill = await client.skills.create({ files: [{ name: "SKILL.md", data: skillMd }, { name: "scripts/read.py", data: script }] });
164
+ ```
165
+
166
+ Also `conversations.retrieve/update/delete`, `conversations.items.create/retrieve/delete`, `chat.completions.retrieve/update/delete`,
167
+ `containers.files.list/retrieve/delete/content.retrieve`, `vectorStores.files.*` and `vectorStores.fileBatches.*`, and
168
+ `skills.versions.*` (a skill is uploaded as its files, each named by its path in the skill, or as one zip).
169
+
170
+ ## The Anthropic API and OpenRouter's, on the same key
171
+
172
+ `client.anthropic` speaks [the Anthropic API](https://dyadic.app/developers/#anthropic) at `/api/anthropic/v1`
173
+ (Claude's request and answer shapes, behaving as Anthropic's do) and `client.openrouter` OpenRouter's API through the
174
+ [AI gateway](https://dyadic.app/developers/#openrouter) at `/api/openrouter/v1`, both with this client's key and beside
175
+ its base URL:
176
+
177
+ ```ts
178
+ const message = await client.anthropic.messages.create({ model: "iris", max_tokens: 1024, messages: [{ role: "user", content: "Hello" }] });
179
+ console.log(message.content, message.stop_reason, message.usage.output_tokens); // Anthropic's answer
180
+
181
+ const stream = await client.anthropic.messages.create({ model: "iris", max_tokens: 1024, stream: true, messages: [{ role: "user", content: "Tell me a story" }] });
182
+ for await (const event of stream) if (event.type === "content_block_delta" && event.delta?.type === "text_delta") process.stdout.write(event.delta.text);
183
+ console.log(stream.usage); // input and output tokens
184
+
185
+ const batch = await client.anthropic.messages.batches.create({ requests: [{ custom_id: "a", params: { model: "iris", max_tokens: 256, messages: [{ role: "user", content: "Hi" }] } }] });
186
+ const results = await client.anthropic.messages.batches.results(batch.id); // once processing_status is "ended"
187
+
188
+ // OpenRouter's models through the AI gateway (Dyadic turns it on for an app's key on request):
189
+ const reply = await client.openrouter.chat.completions.create({ model: "anthropic/claude-haiku-5.5", messages: [{ role: "user", content: "Hello" }] });
190
+ const { data } = await client.openrouter.key(); // this key's limits, answered by Dyadic
191
+ ```
192
+
193
+ Also `anthropic.messages.countTokens` (free), `anthropic.messages.batches.list/retrieve/cancel/delete`,
194
+ `anthropic.models.list/retrieve`, `anthropic.files.create/list/retrieve/content/delete`, and `openrouter.completions`,
195
+ `openrouter.embeddings`, `openrouter.models.list/endpoints`, `openrouter.generation` and `openrouter.credits`. The
196
+ gateway is a platform decision: Dyadic turns it on for an app's key on request, the key's owner can turn it off, and a
197
+ key without it gets `403 gateway_not_allowed`. Anthropic's own SDKs and Claude Code work too, with the base URL
198
+ `https://dyadic.app/api/anthropic`.
199
+
200
+ ## Agent Black Box: a flight recorder for your own agent
201
+
202
+ Record what your agent does into Dyadic's tamper-evident Black Box (hash-chained,
203
+ signed, verifiable offline). Switch **Black Box: send telemetry** (and **read**) on
204
+ for the key first; both are off by default.
205
+
206
+ ```ts
207
+ import { BlackBoxRecorder, verifyBundle } from "dyadic";
208
+
209
+ const recorder = new BlackBoxRecorder(client);
210
+ await recorder.run({ title: "Book a room", agent: "planner" }, async (run) => {
211
+ run.declare({ allowedTools: ["book_room"], allowedHosts: ["rooms.example.com"] });
212
+ await run.toolCall("book_room", { effect: true, hosts: ["rooms.example.com"] }, async (tool) => tool.result(await bookRoom("Orion")));
213
+ run.verified("booking", "confirmation ORN-1042 read back from the booking API");
214
+ });
215
+ await recorder.flush();
216
+ ```
217
+
218
+ Spans nest through their own methods (`run.agentSpan("researcher", {}, async (sub) => sub.toolCall(...))`); a
219
+ remote subagent joins the trace with `recorder.start({ traceparent: span.traceparent() })`. Only metadata is sent
220
+ unless `{ captureContent: true }` (secrets redacted first). `instrumentMcpClient(mcpClient, run)` records MCP tool
221
+ calls. `client.blackbox.runs.export(id)` returns an evidence bundle; `await verifyBundle(bundle, { [keyId]: publicKey })`
222
+ checks it offline with WebCrypto. `client.blackbox.runs.simulate(id, { mode })` never runs anything.
223
+
224
+ ### Reading, triaging and managing
225
+
226
+ With the key's **read** switch: `client.blackbox.overview()`, `settings()`, `runs.list/retrieve/events({ types })/graph/verify/
227
+ checkpoints/otlp/export`, `diff(a, b)`, `findings.list({ status, severity, rule })`, `rules.list()` and
228
+ `rules.preview({ rules })` (what detection would raise with other settings; nothing is saved). **Write**: `bookmarks.create(runId,
229
+ { seq, note })`, `findings.update(id, { status: "dismissed" })`. **Content**: `runs.content(id)`. **Manage**: `runs.erase(id)`,
230
+ `runs.delete(id)` and the sinks below. A run of the key's that was deleted throws `RunDeletedError` (HTTP 410). OTLP protobuf
231
+ goes as it is: `client.blackbox.sendOtlp(bytes)`.
232
+
233
+ ### Live
234
+
235
+ ```ts
236
+ const controller = new AbortController();
237
+ for await (const { event, id, data } of client.blackbox.runs.stream(runId, { signal: controller.signal })) {
238
+ if (event === "event") console.log(id, data.type); // id is the event's sequence number
239
+ if (event === "end") console.log(data.status, data.outcome); // the stream stops after end (or deleted)
240
+ }
241
+ for await (const { event, data } of client.blackbox.watch()) if (event === "run") console.log(data.id, data.status);
242
+ ```
243
+
244
+ Both connect again by themselves when the service asks or the connection drops (a run's stream resumes after its last
245
+ event), and end when you leave the loop or abort the signal. Node 18+ and browsers (fetch and ReadableStream).
246
+
247
+ ### Webhooks and collectors
248
+
249
+ `client.blackbox.sinks.create({ kind: "webhook", url, events: ["finding.created", "run.failed"], min_severity: "high" })` sends
250
+ signed JSON as findings are raised and runs end (keep its `secret`: it is shown once; `sinks.rotate(id)` makes a new one);
251
+ `{ kind: "otlp", url, protocol: "http/protobuf", headers }` exports each finished run to an OpenTelemetry collector. Check every
252
+ webhook before trusting it, with the body exactly as it arrived:
253
+
254
+ ```ts
255
+ import { verifyWebhook, WebhookVerificationError } from "dyadic";
256
+
257
+ export async function POST(request: Request) {
258
+ try {
259
+ const message = await verifyWebhook(await request.text(), request.headers, process.env.BLACKBOX_WEBHOOK_SECRET!);
260
+ // message.type: finding.created, run.completed, run.failed, ...; skip repeats by the webhook-id header
261
+ } catch (error) {
262
+ if (error instanceof WebhookVerificationError) return new Response("bad signature", { status: 400 });
263
+ throw error;
264
+ }
265
+ return new Response(null, { status: 204 });
266
+ }
267
+ ```
268
+
269
+ (Standard Webhooks: HMAC-SHA256, several signatures during a rotation, a timestamp within 5 minutes by default.)
270
+
271
+ ### Anchors
272
+
273
+ When the platform anchors its checkpoint logs (RFC 3161 timestamps over a Merkle root), bundles carry the anchor and
274
+ `verifyBundle` checks it: the anchored checkpoint is in the bundle's log at or after the run's last checkpoint, its Merkle
275
+ proof leads to the root, and the timestamp token stamps that root (its time is in the check's detail). The authority's
276
+ signature on the token is **not** checked here (it needs CMS and certificate work this verifier doesn't do); the report says
277
+ so in its warnings. The Python SDK's `verify_bundle` and the bundle's `verify.py` check it.
278
+
279
+ ### OpenTelemetry
280
+
281
+ If your agent framework already makes OpenTelemetry spans, send them as they are. `BlackBoxSpanExporter` is a
282
+ `SpanExporter` for the official SDK (1.x and 2.x; nothing from OpenTelemetry is needed at runtime):
283
+
284
+ ```ts
285
+ import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
286
+ import { BlackBoxSpanExporter } from "dyadic";
287
+
288
+ const provider = new BasicTracerProvider({ spanProcessors: [new BatchSpanProcessor(new BlackBoxSpanExporter(client))] });
289
+ ```
290
+
291
+ Spans following the GenAI semantic conventions (`gen_ai.operation.name`: `chat`, `execute_tool`, `invoke_agent`, ...)
292
+ or OpenInference (`openinference.span.kind`) become model calls, tool calls and agents; others are kept as generic
293
+ spans; one run per trace. Content attributes (`gen_ai.input.messages`, `gen_ai.tool.call.arguments`, `input.value`,
294
+ ...) stay in your process unless `{ captureContent: true }`, and every value is redacted first (secrets, by default;
295
+ `redact` replaces it). Large batches are split to fit the service (1,000 spans or 4 MB per request), and a failed
296
+ export is reported to the span processor, never thrown. `toOtlpRequests(spans)` gives the OTLP/JSON without sending it.
297
+
298
+ ## Tests
299
+
300
+ `npm test` runs the SDK's own tests against a mocked fetch or a local stand-in server: nothing reaches Dyadic and nothing
301
+ is spent. `npm run test:production` runs the live checks in `live_tests/` against a real API, only with
302
+ `DYADIC_LIVE_API_KEY` set (`DYADIC_LIVE_BASE_URL` for another deployment): free listings and token counts, and two
303
+ messages of at most 16 tokens. Use a short-lived key with a small credit cap.
304
+
305
+ Full guide: https://dyadic.app/developers/