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/CHANGELOG.md +154 -140
- package/LICENSE +21 -21
- package/README.md +305 -305
- package/dist/blackbox.js +87 -23
- package/dist/index.d.ts +21 -3
- package/dist/index.js +43 -8
- package/package.json +15 -15
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/
|