@lionhummer/constellation 0.6.0 → 0.9.1

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,231 +1,398 @@
1
- # @lionhummer/constellation
1
+ # Constellation TypeScript SDK
2
2
 
3
- TypeScript client for Constellation's durable request API. Run it on a trusted server with an execution key. Constellation routes model calls and stores the execution trace; your application supplies messages, tool implementations, optional policy decisions, and billing.
3
+ Server-side client for Constellation. You send a model request; Constellation chooses a provider, runs the call durably (with retries and fallbacks), and tells your server the result. If the model asks for tools, your server runs them and sends the next input.
4
4
 
5
- ```bash
5
+ - Node.js 22 or newer.
6
+ - Server-side only. API keys never belong in browser code, source control or logs.
7
+
8
+ ```sh
6
9
  npm install @lionhummer/constellation
7
10
  ```
8
11
 
9
- ## First request (Node.js 22+)
10
-
11
- An operator must first configure an enabled provider, its credential reference, a model/offering, and access rules for your workspace. Ask them for the API base URL and an **execution** key. A console login is not an SDK credential. The base URL is the host root, without `/v1` or `/internal`.
12
-
13
- Set these variables privately in your server environment:
14
-
15
- - `CONSTELLATION_BASE_URL`: your server's URL (local API: `http://127.0.0.1:8080`).
16
- - `CONSTELLATION_EXECUTION_KEY`: the secret copied when the operator creates your key.
17
- - `CONSTELLATION_WORKSPACE_ID`: the exact workspace allowed by the key and access rules.
18
- - `CONSTELLATION_USER_ID`: required when the key is user-scoped; otherwise optional.
19
- - `CONSTELLATION_PLAN_ID`: optional pricing plan ID, when your operator supplies one.
12
+ ## How it works
13
+
14
+ A **request** is one task. It can take several **model steps**, and each step is one model call. Constellation keeps the request ID the same across all steps.
15
+
16
+ ```text
17
+ Your server Constellation
18
+ ─────────── ─────────────
19
+ requests.submit(input) ─────────────────────▶ chooses a provider, calls the model
20
+ (retries or falls back if a provider fails)
21
+ ◀───── webhook ─────── model.completed: assistant message + tool calls
22
+ run the tools
23
+ submit the next input ──────────────────────▶ step 2: calls the model again
24
+ (inReplyTo: that event)
25
+ ◀───── webhook ─────── model.completed: final answer
26
+ stop: submit "observe" ─────────────────────▶ request completed
27
+ ```
20
28
 
21
- Run the included example to discover accessible models. This does not dispatch a provider:
29
+ Five rules explain almost everything else:
22
30
 
23
- ```sh
24
- node node_modules/@lionhummer/constellation/examples/quickstart.mjs
25
- ```
31
+ 1. **You own the conversation.** Every input contains the complete message history. Constellation does not remember earlier messages for you.
32
+ 2. **Every event gets exactly one answer.** You answer a `model.completed` event either by *continuing*, which sends the next complete input, or by *stopping*, which sends an `observe` response. A second, different answer to the same event is rejected.
33
+ 3. **Nothing waits for you between steps.** After a step completes, the request is in `client_processing` and uses no Constellation capacity until you answer. The request deadline (`timeoutMs`, default 30 minutes) still runs and includes your processing time.
34
+ 4. **Webhooks arrive at least once.** Store each event before you acknowledge it, and expect duplicates.
35
+ 5. **Retries must be identical.** Save every body you send together with its idempotency key. If a call fails or times out, resend exactly the same body. Constellation returns the original result instead of doing the work twice.
26
36
 
27
- Set `CONSTELLATION_MODEL` to a returned `models[].id` and rerun the command. The second run submits a real provider request, prints its ID/events, then shows `completed` and the output text. Provider usage can incur charges. It needs no webhook, tool handler or wait checkpoint.
37
+ ## Quick start: one model call
28
38
 
29
- The complete example is included in the package:
39
+ The simplest use needs no webhook: submit an input and read the result.
30
40
 
31
- ```js
41
+ ```ts
32
42
  import { Constellation, uuid7 } from "@lionhummer/constellation";
33
43
 
34
- function required(name) {
35
- const value = process.env[name];
36
- if (!value) throw new Error(`Set ${name} in your server environment.`);
37
- return value;
38
- }
39
-
40
- const workspaceId = required("CONSTELLATION_WORKSPACE_ID");
41
- const userId = process.env.CONSTELLATION_USER_ID || undefined;
42
- const planId = process.env.CONSTELLATION_PLAN_ID || undefined;
43
44
  const client = new Constellation({
44
- baseUrl: required("CONSTELLATION_BASE_URL"),
45
- apiKey: required("CONSTELLATION_EXECUTION_KEY"),
45
+ baseUrl: process.env.CONSTELLATION_BASE_URL!, // host root, e.g. https://constellation.example.com
46
+ apiKey: process.env.CONSTELLATION_EXECUTION_KEY!,
46
47
  });
47
48
 
48
- // Discovery uses the same scope as execution. It does not call a provider.
49
- const catalog = await client.models.list({ workspaceId, userId, planId });
50
- console.log("Available models:", JSON.stringify(catalog, null, 2));
51
- const model = process.env.CONSTELLATION_MODEL;
52
- if (!model) {
53
- console.log("Set CONSTELLATION_MODEL to one of the returned models[].id values, then rerun.");
54
- } else {
55
- const input = {
56
- idempotencyKey: uuid7(), // Save this input/key if submission needs retrying.
57
- workspaceId,
58
- userId,
59
- planId,
60
- model,
61
- messages: [{ role: "user", text: "Say hello in one short sentence." }],
62
- maxOutputTokens: 256,
63
- timeoutMs: 120_000,
64
- // No tools or wait checkpoints: no webhook or input consumer is needed.
65
- };
66
- const { id } = await client.requests.create(input);
67
- console.log("Request ID:", id);
68
- for await (const event of client.requests.streamEvents(id, {
69
- signal: AbortSignal.timeout(150_000),
70
- })) {
71
- console.log(event.seq, event.type);
72
- }
73
- const { request } = await client.requests.getSummary(id);
74
- console.log("Final status:", request.status);
75
- console.log("Output:", request.output?.text ?? "");
76
- if (request.status !== "completed") {
77
- throw new Error(`Request ${id} ended as ${request.status}: ${request.reason ?? "inspect the request error and attempts"}`);
78
- }
79
- }
80
- ```
49
+ // Find the models this key may use. This does not call a provider.
50
+ const catalog = await client.models.list({ workspaceId: "workspace-123" });
81
51
 
82
- Each invocation creates a new request. For application retries, persist the complete input and its UUIDv7 idempotency key **before** submission and reuse both after an uncertain POST; do not generate a fresh key on each retry. `externalId` groups independent requests and may supply a routing-affinity hint; it does not load conversation history. The observer's abort only stops observing; use `requests.cancel(id)` to cancel execution.
52
+ const input = {
53
+ idempotencyKey: uuid7(), // save input + key so a retry sends the same body
54
+ workspaceId: "workspace-123",
55
+ model: "catalog/example-model", // one of catalog.models[].id
56
+ messages: [{ role: "user" as const, text: "Say hello in one sentence." }],
57
+ };
58
+ const { id } = await client.requests.submit(input);
83
59
 
84
- ## Troubleshooting
60
+ // Live output (optional). Closing the stream does not cancel the request.
61
+ for await (const event of client.requests.streamEvents(id)) {
62
+ console.log(event.sequence, event.type);
63
+ }
85
64
 
86
- | Symptom | What to check |
87
- | --- | --- |
88
- | 401 | Correct host and execution key; the key may have been revoked. Browser cookies do not authenticate the SDK. |
89
- | 403 | Execution permission, matching workspace/user scope and model access rules. Management/telemetry permissions do not imply execution. |
90
- | No models | Operator has enabled a provider, model and offering with an eligible route/access rule. Discovery uses your workspace/user scope. |
91
- | 409 | Read the error code/message. Reuse the exact original input for an idempotency retry; renew an expired input claim before answering. |
92
- | 429 / 503 | Respect `Retry-After`. Retry uncertain admission with the saved input/key. |
93
- | `queued` | A worker must be running and connected to the same PostgreSQL/Redis environment. |
94
- | `waiting` | Handle the pending input. Tools always require a consumer; wait checkpoints do too. SSE observation does not answer inputs. |
95
- | `failed` | Inspect `request.reason`, `request.error`, attempts and timeline for provider, routing or deadline failure. |
65
+ const { request } = await client.requests.getSummary(id);
66
+ console.log(request.status, request.output?.text);
67
+ ```
96
68
 
97
- ## Release 0.4.0
69
+ `events.terminal` defaults to `"off"`, so this request completes after one model step. The package ships a runnable version of this code: set the `CONSTELLATION_*` variables and run `node node_modules/@lionhummer/constellation/examples/quickstart.mjs`.
98
70
 
99
- Telemetry now returns metadata by default. Use `include: "content"` for full content, with the same permission/scope. Adds key-aware `discover`, explicit request timings, accurate endpoint filter types, HTTP chunk pagination, and billing/traffic methods. Deploy the matching server before using these methods.
71
+ ## Build an agent with tools
100
72
 
101
- ## Release 0.3.0
73
+ A tool-using agent needs four pieces:
102
74
 
103
- This pre-1.0 release breaks the earlier SDK contract. Use namespaced execution methods; flat aliases and the old drive/protocol surface are removed. Keys, credentials, users and capacity administration are internal-only. Provider-definition writes also require an internal operator. External telemetry and other registry authoring remain available. Deploy a server containing these API changes before switching clients; publishing this package does not deploy the server or upgrade any downstream application dependency.
75
+ 1. **A webhook** that receives events and stores them.
76
+ 2. **Hooks** that run your tools and build the next input.
77
+ 3. **An `EventStore`**: a table in your database that remembers each event and the response you prepared for it.
78
+ 4. **`processEvent`**, which ties the other three together.
104
79
 
105
- ## Events and caller work
80
+ ### 1. Turn on events
106
81
 
107
- All event modes default to `off`. `beforeModel`, `afterModel`, and `afterStep` accept `off`, `notify`, or `wait`. `outputDelta` and `terminal` accept `off` or `notify`. Notifications are delivered to the execution key's configured webhook. A wait creates a durable input that your application must claim and answer. Tool calls always create durable inputs, including parallel calls.
82
+ An operator sets a webhook URL and a webhook token on your execution key, in the console under **Keys**. Then request events on every input:
108
83
 
109
- `afterStep` happens after the model when it has no tool calls, or after all tool results. It includes output, usage, cost, and tool results. Use it to settle billing once and decide whether to complete or continue. Continuing requires a complete next `stepInput`, including model, messages, instructions, tools, parameters, output limit, and cache policy. Constellation routes each later step separately.
84
+ ```ts
85
+ const first = {
86
+ idempotencyKey: uuid7(),
87
+ workspaceId: "workspace-123",
88
+ externalId: "conversation-42", // optional: your own ID, useful for searching
89
+ model: "catalog/example-model",
90
+ instructions: "You are a helpful assistant.",
91
+ messages: [{ role: "user" as const, text: "What's the weather in Paris?" }],
92
+ tools: [{
93
+ name: "get_weather",
94
+ description: "Current weather for a city",
95
+ inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
96
+ }],
97
+ events: { terminal: "notify" as const }, // send model.completed / failed / cancelled to the webhook
98
+ };
99
+ await saveConversation(first); // your storage: you need the history for step 2
100
+ await client.requests.submit(first);
101
+ ```
110
102
 
111
- The following integration sketches contain application-owned functions such as `lookupOnce`, `settleOnce` and `buildCompleteNextStep`; implement those before enabling tools or wait checkpoints. Type handlers explicitly so TypeScript checks their callback arguments:
103
+ If the key has no webhook configured, admission rejects `terminal: "notify"`.
104
+
105
+ ### 2. Write the hooks
112
106
 
113
107
  ```ts
114
- import type { InputHandlers } from "@lionhummer/constellation"
108
+ import type { AgentHooks } from "@lionhummer/constellation";
115
109
 
116
- const handlers: InputHandlers = {
117
- consumerId: "stable-worker-id",
110
+ const hooks: AgentHooks = {
111
+ // One handler per tool name. Calls from one model step run in parallel (toolConcurrency, default 8).
118
112
  tools: {
119
- lookup: async (args, { inputId, signal }) => lookupOnce(args, inputId, signal),
120
- },
121
- checkpoints: {
122
- afterStep: async (payload, state, { inputId, parentSpanId, signal }) => {
123
- await settleOnce(inputId, payload, { parentSpanId, signal })
124
- return shouldContinue(state)
125
- ? { action: "continue", stepInput: await buildCompleteNextStep(state) }
126
- : { action: "complete" }
113
+ get_weather: async (args, { effectId, signal }) => {
114
+ // effectId is stable for this tool call. Use it to make side effects idempotent.
115
+ return weatherApi.current((args as { city: string }).city, { requestId: effectId, signal });
127
116
  },
128
117
  },
129
- }
130
118
 
131
- await client.inputs.handle(id, inputId, handlers)
119
+ // Optional. Default: continue if the model called tools, otherwise stop.
120
+ onStepComplete: async ({ event, toolResults }) => (toolResults.length ? "continue" : "stop"),
121
+
122
+ // Required to continue. Return the COMPLETE next input.
123
+ prepareRequest: async ({ event, toolResults }) => {
124
+ const conversation = await loadConversation(event.requestId);
125
+ const messages = [
126
+ ...conversation.messages,
127
+ event.data.message, // the assistant message, including its tool calls
128
+ { role: "tool" as const, toolResults }, // your results, matched by tool-call id
129
+ ];
130
+ await saveMessages(event.requestId, messages);
131
+ return { ...conversation.settings, messages };
132
+ },
133
+
134
+ // Optional. Called when a hook throws and when the model step failed or was cancelled.
135
+ onError: async (error, { event }) => {
136
+ logger.warn({ requestId: event.requestId, error }, "agent step failed");
137
+ },
138
+ };
132
139
  ```
133
140
 
134
- `inputs.handle` processes one named input, which suits webhook delivery. `inputs.poll` continuously inspects and handles inputs until terminal and must be started explicitly. Both claim and renew inputs while handlers run. Side effects should be deduplicated by input ID because delivery and responses can be retried. Low-level `inputs.claim` and `inputs.respond` are available when you manage the lifecycle yourself.
141
+ `saveConversation`, `loadConversation`, `saveMessages`, `weatherApi` and `logger` stand for your own code.
135
142
 
136
- ## Webhook
143
+ `conversation.settings` must repeat the first request's `workspaceId`, `userId`, `externalId`, `model`, `planId`, `events` and `timeoutMs`. A continuation that changes any of them is rejected. You may change `messages`, `instructions`, `tools`, `parameters` and `cache`.
137
144
 
138
- Configure an HTTPS callback URL and token on the execution key. Verify `x-constellation-webhook-token`; return 2xx after your effects are durable. Transient non-2xx responses are retried. A permanent input rejection can use HTTP 422 with the documented `constellation_input_rejected` body; repeated 401/403 stops delivery. Return 2xx only after durable effects. Model text/reasoning chunks arrive as `request.events` when `outputDelta` is `notify`; terminal notification carries a request ID and event sequence.
145
+ ### 3. Implement the `EventStore`
139
146
 
140
- ```ts
141
- const event = await client.webhooks.verify(request, webhookToken)
142
- if ("inputId" in event) {
143
- await client.inputs.handle(event.requestId, event.inputId, handlers)
144
- } else if (event.kind === "request.events") {
145
- await storeEventBatchOnce(event.requestId, event.afterSeq, event.events)
146
- } else {
147
- await finalizeOnce(event.requestId, event.eventSeq,
148
- await client.requests.getSummary(event.requestId))
149
- }
150
- ```
147
+ The store is a small table in your own database. It guarantees two things:
151
148
 
152
- ## Inspection and other APIs
149
+ - an event you acknowledged is never lost;
150
+ - a response you prepared is sent unchanged, even after a crash.
153
151
 
154
- `requests.get` returns the request and its records. For long executions, use `requests.getSummary`, `requests.listRecords`, `requests.getTimeline`, and the paginated request list. `requests.listEvents` reads the durable event journal; `requests.streamEvents` reconnects SSE from a cursor. Stopping an observer does not cancel execution; use `requests.cancel` explicitly.
152
+ Do not use an in-memory map in production. Here is a complete PostgreSQL version using [`pg`](https://node-postgres.com/):
155
153
 
156
- Execution methods are top-level (`models`, `requests`, `inputs`, `spans`, `logs`). Registry authoring lives under `management.registry`; read-only operational data lives under `telemetry`. API-key administration, provider credentials, users and capacity controls are internal operator APIs and are absent from this external SDK. The SDK requires a bearer key, omits browser cookies, and has no `/internal` routing option. Provider definitions are readable externally; imports, changes and rollback that modify providers require internal operator access because endpoints and credential references control where stored secrets are sent. The same API timeline is shown in the console. Caller spans can attach to a wait using `inputId` and nest beneath a span using `parentSpanId`.
154
+ ```sql
155
+ CREATE TABLE constellation_events (
156
+ event_id text PRIMARY KEY,
157
+ payload jsonb NOT NULL,
158
+ prepared boolean NOT NULL DEFAULT false,
159
+ response jsonb, -- the exact body we send (or resend)
160
+ state text NOT NULL DEFAULT 'pending', -- pending | done | rejected
161
+ rejection jsonb,
162
+ failures integer NOT NULL DEFAULT 0,
163
+ last_error jsonb,
164
+ first_failure_at timestamptz,
165
+ last_failure_at timestamptz,
166
+ received_at timestamptz NOT NULL DEFAULT now()
167
+ );
168
+ ```
157
169
 
158
- Use the documented namespaces throughout: `requests.create`, `requests.get`,
159
- `requests.getSummary`, `requests.preview`, `requests.listRecords`,
160
- `requests.streamEvents`, `inputs.claim`, `inputs.respond`, `spans.start`,
161
- `spans.add`, and `logs.add`. Their implementation helpers are private; flat
162
- method aliases are not part of the client API.
170
+ ```ts
171
+ import pg from "pg";
172
+ import { PermanentEventError, type EventStore, type WebhookEvent } from "@lionhummer/constellation";
173
+
174
+ const db = new pg.Pool();
175
+
176
+ export const store: EventStore & { pending(): Promise<WebhookEvent[]> } = {
177
+ // Save the event before acknowledging the webhook. Duplicates must match.
178
+ async accept(event) {
179
+ await db.query(
180
+ `INSERT INTO constellation_events (event_id, payload) VALUES ($1, $2)
181
+ ON CONFLICT (event_id) DO NOTHING`, [event.id, event]);
182
+ const { rows } = await db.query(
183
+ `SELECT payload = $2::jsonb AS same FROM constellation_events WHERE event_id = $1`, [event.id, event]);
184
+ if (!rows[0]?.same) throw new Error(`event ${event.id} redelivered with a different payload`);
185
+ },
163
186
 
164
- ## Caller failures and delivery retries
187
+ // Run `prepare` at most once per event at a time, and save its result before returning it.
188
+ async prepare(event, prepare) {
189
+ const tx = await db.connect();
190
+ try {
191
+ await tx.query("BEGIN");
192
+ const { rows } = await tx.query(
193
+ `SELECT prepared, response, state, rejection FROM constellation_events
194
+ WHERE event_id = $1 FOR UPDATE`, [event.id]); // serializes concurrent processing
195
+ const row = rows[0];
196
+ if (row.state === "rejected") {
197
+ const r = row.rejection;
198
+ throw new PermanentEventError(r.status, r.code, r.message);
199
+ }
200
+ if (row.prepared) { await tx.query("COMMIT"); return row.response; }
201
+ const response = await prepare(); // runs your tools and hooks
202
+ await tx.query(
203
+ `UPDATE constellation_events SET prepared = true, response = $2 WHERE event_id = $1`,
204
+ [event.id, response]);
205
+ await tx.query("COMMIT");
206
+ return response;
207
+ } catch (error) {
208
+ await tx.query("ROLLBACK");
209
+ throw error;
210
+ } finally {
211
+ tx.release();
212
+ }
213
+ },
165
214
 
166
- A failed webhook delivery is one attempt, not the final request outcome. Constellation retains failed attempts even when a later retry succeeds. Retryable delivery warnings include the input ID, attempt number, callback HTTP failure and retry delay; terminal failures remain errors. The callback drawer shows that delivery and the current request state.
215
+ async complete(eventId) {
216
+ await db.query(`UPDATE constellation_events SET state = 'done' WHERE event_id = $1`, [eventId]);
217
+ },
167
218
 
168
- The SDK records checkpoint and tool exceptions in `attributes.error` on the caller span and preserves the original exception. Root-cause name, bounded message and optional code are included; SQL dumps, stack traces and arbitrary error object fields are omitted, and common credential forms are redacted. This is best-effort redaction: keep sensitive values out of exception messages. Closed error spans with these details produce one linked request error log. Keep complete diagnostics in your application's own logs. Historical spans without error attributes cannot reconstruct the original exception.
219
+ // A permanent rejection: never resend this response.
220
+ async reject(eventId, error) {
221
+ await db.query(
222
+ `UPDATE constellation_events SET state = 'rejected', rejection = $2 WHERE event_id = $1`,
223
+ [eventId, { status: error.status, code: error.code, message: error.message }]);
224
+ },
169
225
 
170
- ## Discover available HTTP operations
226
+ // Optional: a retryable failure. Alert when failures pile up or get old.
227
+ async retry(eventId, error) {
228
+ await db.query(
229
+ `UPDATE constellation_events SET failures = failures + 1, last_error = $2,
230
+ first_failure_at = coalesce(first_failure_at, now()), last_failure_at = now()
231
+ WHERE event_id = $1`, [eventId, error]);
232
+ },
171
233
 
172
- ```ts
173
- const contract = await client.discover();
174
- console.log(contract["x-constellation-access"]);
175
- console.log(Object.keys(contract.paths));
234
+ // Not part of EventStore: used by the recovery sweep below.
235
+ async pending() {
236
+ const { rows } = await db.query(
237
+ `SELECT payload FROM constellation_events WHERE state = 'pending' ORDER BY received_at LIMIT 100`);
238
+ return rows.map((row) => row.payload);
239
+ },
240
+ };
176
241
  ```
177
242
 
178
- `discover(signal?)` calls `GET /v1/discover` with the client's bearer key and returns a typed OpenAPI document filtered to that key's permissions and workspace/user scope. It works with execution, management or telemetry keys. `/v1/openapi.json` is the secondary HTTP path. This describes available operations; resource ownership, model access and request lifecycle are still validated when calling them. Discovery does not grant additional access. Requires a server with the discovery endpoint deployed.
243
+ `prepare` keeps a row lock while your tools run. That is acceptable for tools that finish in seconds or minutes. For very long work, replace the lock with a lease column.
179
244
 
180
- ## Scoped telemetry and timing
245
+ ### 4. Receive webhooks and process events
181
246
 
182
- Telemetry returns metadata by default: request identity/status, timestamps, costs, token counts, attempt commitment/fallback flags and record metadata. Full request/log/HTTP content requires `include: "content"`; this uses the same telemetry permission and key scope, with no extra permission. SDK content fields are optional because the default omits them. Workspace and user scope applies to hot and archived requests, events, records, HTTP, timing and timeline navigation. Global telemetry endpoints require an unscoped key.
247
+ Acknowledge quickly, then process in the background. The webhook handler below works with any framework that uses the web `Request`/`Response` types (Hono, Next.js route handlers, Bun, Deno):
183
248
 
184
- ```typescript
185
- const page = await client.telemetry.requests.list({ externalId: "thread-123", limit: 25 });
186
- for (const request of page.items) {
187
- const timing = await client.telemetry.requests.getTimings(request.id);
188
- console.log(request.status, timing.queueMs, timing.attempts);
249
+ ```ts
250
+ import { processEvent, PermanentEventError } from "@lionhummer/constellation";
251
+
252
+ export async function webhook(request: Request): Promise<Response> {
253
+ let event;
254
+ try {
255
+ event = await client.webhooks.verify(request, process.env.CONSTELLATION_WEBHOOK_TOKEN!);
256
+ } catch {
257
+ return new Response(null, { status: 401 });
258
+ }
259
+ await store.accept(event); // if this throws, the 5xx makes Constellation redeliver
260
+ void handle(event); // don't make the webhook wait for your tools
261
+ return new Response(null, { status: 202 });
189
262
  }
190
- const detail = await client.telemetry.requests.get(requestId, { include: "content" });
191
- const logs = await client.telemetry.requests.listRecords(requestId, "logs", {
192
- level: "warn", offset: 0, limit: 25, include: "content",
193
- });
194
- const body = await client.telemetry.requests.getHttpBody(requestId, attemptId, {
195
- direction: "response", offset: 0, limit: 16384, include: "content",
196
- });
263
+
264
+ async function handle(event: WebhookEvent) {
265
+ try {
266
+ await processEvent(client, store, event, hooks);
267
+ } catch (error) {
268
+ if (error instanceof PermanentEventError) {
269
+ await markConversationFailed(event.requestId, error); // will not be retried
270
+ } else {
271
+ logger.warn({ eventId: event.id, error }, "event processing will be retried");
272
+ }
273
+ }
274
+ }
275
+
276
+ // Optional: resume unfinished events right after a restart.
277
+ for (const event of await store.pending()) void handle(event);
197
278
  ```
198
279
 
199
- `requests.getTimings` is also available with execution authority. Timing responses include admission, queue, worker preparation, provider attempts and caller waits. Admission and preparation have recorded child/detail spans; IDs and provenance match the console. Concurrent waits count once in `callerWaitMs`. Missing/incomplete measurements are null. Read `complete`, `truncated` and `asOf`; grouped totals can overlap and are not additive.
280
+ Don't run tools inside the webhook call. Constellation waits 5 seconds for the acknowledgement by default, and nothing in Constellation waits for your tools.
200
281
 
201
- Request lists accept `RequestListQuery`, including exact `externalId` and requested-model filters, exclusions and substring matches. Record lists accept `RequestRecordsQuery` (`id`, `q`, log-only `level`, `offset`, `limit`). `PageQuery.filters` belongs only to registry list rows. Every other method has its endpoint's exact query type. See the [HTTP filter matrix](../../docs/api.md#filter-audit-and-exact-query-support) for search semantics, caps, cursor behavior and capabilities not currently provided.
282
+ If your process dies while handling an event, you don't need a poller to recover it. Constellation sends an unanswered `model.completed` event again after the response window (10 minutes by default), up to 3 times, and stops as soon as your response arrives. A re-sent event has the same `id`, so `store.accept` and `store.prepare` resume it from your saved checkpoint. If the event is still being processed when the re-send arrives, acknowledge it anyway. Keep your processing lease shorter than the response window and renew it while work continues, so a re-send after a crash can take the event over.
202
283
 
203
- Unscoped telemetry also exposes `observability.getBilling`, `getTraffic`, `getTiming`, dashboard and measurement series. Use `providerId` for local series and `upstream` for gateway series. `system.getComponent(component, { status, statusNot, q, offset })` and `jobs.listHistory` page matching operational records; notifications use fixed 25-item pages and status/offset, not generic filters.
284
+ ## What `processEvent` does
204
285
 
205
- ### Supplier cost, customer basis and margin
286
+ For each event:
206
287
 
207
- Each provider attempt records immutable `attributes.billing` on its Constellation `provider.call` span. The `step.usage_ready` event carries the same `billing` facts. Both are included in default telemetry metadata. `supplierPrice`/`supplierCost` record the selected supplier rates and usage cost. `customerPricing` identifies the model or offering basis, plan, scoped rule and adjustment; `customerBasisPrice`/`customerBasisCost` record the customer calculation before adjustment. `customerPrice`/`customerCost` record the final rates and charge. `grossMarginAmount` is charge minus supplier cost; `grossMarginPercent` divides it by charge. Unknown costs leave margins null, and a zero charge has a null margin percentage. These costs use provider-reported usage and captured schedules, not reconciled supplier invoices. Prices are USD per million tokens; costs are decimal USD strings.
288
+ 1. **Prepare the response, at most once.** `store.prepare` returns the saved response if one exists. Otherwise the SDK builds one:
289
+ - For `model.completed`, it runs every tool call in parallel and calls `onStepComplete`. To continue, it calls `prepareRequest`, which produces a continuation. To stop, it produces an `observe` response.
290
+ - For `model.failed` and `model.cancelled`, it calls `onError(event.data)` and produces an `observe` response.
208
291
 
209
- Provider cost capture is optional: `billing.amountPointer` locates a provider-reported amount in response JSON and `unitsPerUsd` converts it to USD. `reportedSupplierCost` is retained only for analysis; it never changes supplier cost, customer basis, charges or margin. Supplier cost is calculated from reported token usage and captured supplier rates. `supplierCostDifference` is reported minus calculated cost when both are known. The offering selects `customerPriceBasis:model|offering`; plans apply the customer adjustment.
292
+ The response carries the idempotency key `event.id`, `inReplyTo: { requestId, eventId }` and the telemetry recorded while preparing. The store saves it before anything is sent.
293
+ 2. **Submit it** through `requests.submit`. Retries send the saved body unchanged.
294
+ 3. **Record the outcome**:
210
295
 
211
- ### Standard settings and headers
296
+ | Server answer | Meaning | `processEvent` |
297
+ | --- | --- | --- |
298
+ | `200`, `outcome: "accepted"` | Your response was accepted. A continuation starts the next step (`scheduled: true`). | `store.complete`, resolves |
299
+ | `200`, `outcome: "already_accepted"` | A retry of a response that was already accepted, for example after a lost reply. Nothing runs twice. | `store.complete`, resolves |
300
+ | `200`, `outcome: "event_closed"` | The request was cancelled or expired before your continuation arrived. Nothing continues; your telemetry is still recorded (`observationsRecorded`). | `store.complete`, resolves |
301
+ | `400 bad_request` | The response itself is invalid, for example a changed model or a bad message. | `store.reject`, throws `PermanentEventError` |
302
+ | `409 response_conflict` | A *different* response to this event was already accepted. | `store.reject`, throws `PermanentEventError` |
303
+ | `410 observations_expired` | The request ended more than 24 hours ago. | `store.reject`, throws `PermanentEventError` |
304
+ | Anything else: network error, timeout, `5xx`, `429`, `409 conflict`, `401`, `403` | Temporary. | `store.retry`, throws. The next attempt resends the same body. |
212
305
 
213
- Use `parameters.reasoningEffort` for reasoning effort across providers. The model discovery schema defines its allowed values and other supported settings. Constellation translates fields and values for the selected offering. Required unsupported settings reject; `optionalParameters` permits declared omissions with warnings.
306
+ The permanent codes are exported as `PERMANENT_RESPONSE_CODES`. The receipt passed to `store.complete` includes `outcome`, so your store can record that a request ended without continuing.
214
307
 
215
- The standard Reasoning Effort vocabulary is `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`; Verbosity is `low`, `medium`, or `high`; Service Tier is `auto`, `default`, `flex`, or `priority`. Discovery lists the values accessible to your key and eligible routes. A model’s standard definitions cannot be renamed or given additional values; custom parameters have separately authored definitions. Provider wire names and values are translated internally.
308
+ Fix `401` and `403` by correcting the key: the saved response is still sent unchanged afterwards.
216
309
 
217
- For cache diagnostics, send `cache: { mode: "automatic", diagnostics: true }` on the first step. `providerResponseId` appears on attempts and in the `afterModel` checkpoint payload. Use that upstream ID as a later step’s `cache.comparisonId`, which also enables diagnostics. A successful cache hit may return no diagnostic payload. Telemetry exposes the response ID by default; raw `cacheDiagnostics` requires `include: "content"`. Diagnostics and prewarm are offering capabilities: discover support before requesting them. Invalid TTLs or unsupported required cache settings fail rather than being silently dropped.
310
+ ## When your code fails
218
311
 
219
- Send beta tokens as `headers: { "anthropic-beta": "your-beta-token" }`, not inside `parameters`. The provider connection must allow the header name. Unapproved, protected or malformed headers are dropped with warnings. A permitted header can still contain an upstream-unsupported beta token; the provider's own error remains visible. Cache diagnostics can add their required beta separately. Obvious may choose provider-specific prompt placement and cache boundaries while using Constellation's standard cache request.
312
+ | What happened | Without `onError` | With `onError` |
313
+ | --- | --- | --- |
314
+ | A tool or hook throws | `processEvent` throws and the event is retried later. Tools may run again, so use `effectId`. | `onError` runs. If it returns, the request stops and the error is recorded in telemetry. If it throws, the event is retried. |
315
+ | No handler for a tool the model called, or `prepareRequest` is missing when continuing | The request is marked **failed** and `HookConfigurationError` (a `PermanentEventError`) is thrown. | `onError` runs, as above. |
316
+ | The model step failed or was cancelled (`model.failed`, `model.cancelled`) | An `observe` response records your telemetry. | `onError(event.data)` runs first. |
220
317
 
221
- ## Reasoning history and model handoffs
318
+ ## Side effects and duplicates
222
319
 
223
- Retain the complete `nativeContent` artifact from assistant output alongside canonical text, tool calls and results. Reasoning summaries alone cannot reconstruct signed/encrypted state. Use `nativeContentPolicy: "omitIncompatible"` on submitted or prepared steps to permit cross-family handoffs, including active tool continuations. Constellation omits only incompatible native state in the outbound attempt and records a warning; the original history remains intact. `require` excludes incompatible routes; `omitCompleted` permits omission only from completed turns.
320
+ Hooks can run more than once. A crash after a tool ran, but before the prepared response was saved, means the tool runs again. Constellation cannot make your side effects exactly-once; your code must:
224
321
 
225
- Same-protocol Claude/Responses model switches let providers verify native readability. Responses account scopes must be verified; Gemini also requires the model replay group. Preserve the exact prompt prefix for signed thinking. Provider-reported thinking drops and allowed prefix mismatches appear in provider span `attributes.reasoningTransformations`; a successful response does not prove retention. See [the HTTP contract](../../docs/api.md#provider-protocols).
322
+ - pass `effectId` to anything with side effects (payments, emails, writes). It is stable for one tool call of one event;
323
+ - settle per-step billing or usage once per `event.id`;
324
+ - honour the `signal` you receive and put timeouts on external calls.
226
325
 
227
- ### Tool timing
326
+ ## Live output
228
327
 
229
- `inputs.handle` and `inputs.poll` record tool-input inspection, claim and response POST phases. A tool handler receives `context.parentSpanId` and `context.recordSpan(span)`; use the latter to attach closed preparation/execution spans without an HTTP call per phase. Child spans are batched with the tool-span close. Keep spans linked to the current input and omit arguments, results and secrets from attributes. The result POST observation is best effort: a request that has already ended rejects late telemetry, without replaying the committed response.
328
+ `requests.streamEvents(id, { after, signal })` streams `model.delta` events (text and reasoning as it is generated), followed by each step's final event. It reconnects automatically. To resume after a restart, pass the last `event.sequence` you processed as `after`. Streaming is for display only: run tools from the webhook, never from the stream. Stopping the stream does not cancel the request; `requests.cancel(id)` does.
230
329
 
231
- Callback round trips include your handler's processing time. Compare the input wait, tool span and child execution phases rather than adding their overlapping durations. Monotonic `elapsedMs` values are retained in metadata-only telemetry; timestamps across hosts assume aligned clocks.
330
+ ## Telemetry
331
+
332
+ Hooks receive a `telemetry` object. Spans and logs you record are attached to the step that produced the event, so they appear in the request's timeline in the console next to Constellation's own spans:
333
+
334
+ ```ts
335
+ tools: {
336
+ search: async (args, { telemetry }) =>
337
+ telemetry.span("search.query", { index: "docs" }, async (span) => {
338
+ span.log("info", "querying index");
339
+ return searchIndex(args);
340
+ }),
341
+ },
342
+ ```
343
+
344
+ The SDK records `client.processing` and one span per tool automatically. A response carries at most 512 spans, 128 logs and 1 MiB of telemetry; the SDK trims larger batches and records what it omitted. Do not put secrets or personal data in span names, attributes or log messages.
345
+
346
+ ## Limits and timeouts
347
+
348
+ | Setting | Default | Notes |
349
+ | --- | --- | --- |
350
+ | `timeoutMs` | 30 minutes | Deadline for the whole request, including your processing between steps. |
351
+ | `compressRequests` client option | `true` | Request bodies of 16 KiB or more are sent gzip-compressed. Every step resends the full conversation, so this cuts upload time several-fold. |
352
+ | `attemptTimeoutMs`, `firstByteTimeoutMs`, `idleTimeoutMs` | not set | Optional per-provider-call limits. Operators also run stall watchdogs: by default 120 s to the first response byte and 300 s with no data. Keepalives count as data. |
353
+ | `maxProviderAttempts` | 32 | Provider attempts per step, including fallbacks (1–256). |
354
+ | `toolConcurrency` hook option | 8 | Parallel tool calls per event (1–128). |
355
+ | Telemetry per response | 512 spans, 128 logs, 1 MiB | Larger batches are trimmed by the SDK. |
356
+ | Late observations | 24 hours | After the request ends, `observe` responses are accepted for 24 hours. |
357
+ | Retry identity | 7 days (operator setting) | After a request ends, an identical retry of its `idempotencyKey` returns the original request for this long. Later, the same key starts a new request. |
358
+
359
+ A failed provider call falls back to the next provider only before any output has been streamed. After that, the step fails instead of mixing output from two models.
360
+
361
+ ## Client reference
362
+
363
+ | Method | What it does |
364
+ | --- | --- |
365
+ | `models.list(scope)` | Models this key can use, with supported parameters. No provider call. |
366
+ | `models.resolve(input)` | Which providers would serve a model, without calling them. |
367
+ | `requests.submit(input)` | Start a request, continue it (`inReplyTo`), or stop it (`action: "observe"`). |
368
+ | `requests.preview(input)` | Validate a complete input and show routing, without calling a provider. |
369
+ | `requests.cancel(id)` | Cancel a request. |
370
+ | `requests.getState(id)` | Committed state: status, steps, attempt outcomes, usage and cost. |
371
+ | `requests.getSummary(id)`, `requests.get(id)` | Request detail, without or with spans and logs. |
372
+ | `requests.list(query)` | Page through requests, e.g. by `externalId` or `status`. |
373
+ | `requests.listEvents(id, after)`, `requests.streamEvents(id)` | Replay events after a sequence number, or stream them live. |
374
+ | `requests.listRecords(id, kind)` | Page steps, attempts, spans or logs. |
375
+ | `requests.getTimeline(id)`, `requests.getTimings(id)` | Trace and timing breakdown. |
376
+ | `requests.getHttpExchange(id, attemptId)`, `requests.getHttpBody(...)` | The provider HTTP call captured for one attempt. |
377
+ | `webhooks.verify(request, token)` | Authenticate and parse an incoming webhook. |
378
+ | `telemetry.*` | Read-only reporting. Returns metadata only unless you pass `include: "content"`. Needs telemetry permission. |
379
+ | `management.registry.*` | Edit the model registry. Needs management permission. |
380
+ | `discover()` | The OpenAPI document for exactly what this key may call. |
381
+
382
+ Exports for event processing: `processEvent`, `prepareResponse` (build a response without submitting it), `EventReceiver`, `ClientTelemetry`, `PermanentEventError`, `HookConfigurationError`, `PERMANENT_RESPONSE_CODES`, `ConstellationError` and `uuid7`.
383
+
384
+ ## Troubleshooting
385
+
386
+ | Symptom | What to check |
387
+ | --- | --- |
388
+ | `models.list` is empty | The key's workspace/user scope, an access rule for that workspace, and an enabled model and offering. |
389
+ | `terminal: "notify"` is rejected | The execution key has no webhook URL configured. |
390
+ | The same webhook keeps arriving | Your endpoint returned non-2xx or took longer than the delivery timeout (5 s by default). Return 202 after `store.accept` and process in the background. |
391
+ | `400` on a continuation | Model, workspace, user, `externalId`, plan, events or `timeoutMs` differ from the first request, or the message history is malformed. |
392
+ | `409 response_conflict` | Your code produced a different response for an event that was already answered. Answers must come from the saved response. |
393
+ | Tools ran twice | Expected after a crash. Make side effects idempotent with `effectId`. |
394
+ | A step finished with `outcome: "event_closed"` | The request was cancelled or its `timeoutMs` passed while your code was working. |
395
+ | Client spans missing from the timeline | Telemetry is sent with the response. Check that `processEvent` reached the server. |
396
+ | Request expired while tools were running | `timeoutMs` covers your processing time too. Raise it on the first request. |
397
+
398
+ More detail: [application integration](../../docs/integration.md), [HTTP API](../../docs/api.md) and the [OpenAPI document](../../docs/registry-api.openapi.json).