@lionhummer/constellation
v0.16.0
Published
TypeScript client for durable model requests and model events
Readme
Constellation TypeScript SDK
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.
- Node.js 22 or newer.
- Server-side only. API keys never belong in browser code, source control or logs.
npm install @lionhummer/constellationHow it works
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.
Your server Constellation
─────────── ─────────────
requests.submit(input) ─────────────────────▶ chooses a provider, calls the model
(retries or falls back if a provider fails)
◀───── webhook ─────── model.completed: assistant message + tool calls
run the tools
submit the next input ──────────────────────▶ step 2: calls the model again
(inReplyTo: that event)
◀───── webhook ─────── model.completed: final answer
stop: submit "observe" ─────────────────────▶ request completedFive rules explain almost everything else:
- You own the conversation. Every input contains the complete message history. Constellation does not remember earlier messages for you.
- Every event gets exactly one answer. You answer a
model.completedevent either by continuing, which sends the next complete input, or by stopping, which sends anobserveresponse. A second, different answer to the same event is rejected. - Nothing waits for you between steps. After a step completes, the request is in
client_processingand uses no Constellation capacity until you answer. The request deadline (timeoutMs, default 30 minutes) still runs and includes your processing time. - Webhooks arrive at least once. Store each event before you acknowledge it, and expect duplicates.
- 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.
Quick start: one model call
The simplest use needs no webhook: submit an input and read the result.
import { Constellation, uuid7 } from "@lionhummer/constellation";
const client = new Constellation({
baseUrl: process.env.CONSTELLATION_BASE_URL!, // host root, e.g. https://constellation.example.com
apiKey: process.env.CONSTELLATION_EXECUTION_KEY!,
});
// Telemetry and management keys act for a person. Pass that person's email as `actingFor`; calls to
// /v1/telemetry and /v1/management without it fail with `acting_for_required`:
// new Constellation({ baseUrl, apiKey: process.env.CONSTELLATION_TELEMETRY_KEY!, actingFor: "[email protected]" })
// Find the models this key may use. This does not call a provider.
const catalog = await client.models.list({ workspaceId: "workspace-123" });
const input = {
idempotencyKey: uuid7(), // save input + key so a retry sends the same body
workspaceId: "workspace-123",
model: "catalog/example-model", // one of catalog.models[].id
messages: [{ role: "user" as const, text: "Say hello in one sentence." }],
};
const { id } = await client.requests.submit(input);
// Live output (optional). Closing the stream does not cancel the request.
for await (const event of client.requests.streamEvents(id)) {
console.log(event.sequence, event.type);
}
const { request } = await client.requests.getSummary(id);
console.log(request.status, request.output?.text);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.
Build an agent with tools
A tool-using agent needs three pieces:
- A webhook that receives events.
- Hooks that run your tools and build the next input.
- An
EventReceiver, which takes each event, runs your hooks in the background and sends the response. You don't store events or responses: Constellation re-sends an event that stays unanswered, and deduplicates your answers.
1. Turn on events
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:
const first = {
idempotencyKey: uuid7(),
workspaceId: "workspace-123",
externalId: "conversation-42", // optional: your own ID, useful for searching
model: "catalog/example-model",
instructions: "You are a helpful assistant.",
messages: [{ role: "user" as const, text: "What's the weather in Paris?" }],
tools: [{
name: "get_weather",
description: "Current weather for a city",
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
}],
events: { terminal: "notify" as const }, // send model.completed / failed / cancelled to the webhook
};
await saveConversation(first); // your storage: you need the history for step 2
await client.requests.submit(first);If the key has no webhook configured, admission rejects terminal: "notify".
2. Write the hooks
import type { AgentHooks } from "@lionhummer/constellation";
const hooks: AgentHooks = {
// One handler per tool name. Calls from one model step run in parallel (toolConcurrency, default 8).
tools: {
get_weather: async (args, { effectId, signal }) => {
// effectId is stable for this tool call. Use it to make side effects idempotent.
return weatherApi.current((args as { city: string }).city, { requestId: effectId, signal });
},
},
// Optional. Default: continue if the model called tools, otherwise stop.
onStepComplete: async ({ event, toolResults }) => (toolResults.length ? "continue" : "stop"),
// Required to continue. Return the COMPLETE next input.
prepareRequest: async ({ event, toolResults }) => {
const conversation = await loadConversation(event.requestId);
const messages = [
...conversation.messages,
event.data.message, // the assistant message, including its tool calls
{ role: "tool" as const, toolResults }, // your results, matched by tool-call id
];
await saveMessages(event.requestId, messages);
return { ...conversation.settings, messages };
},
// Optional. Called when a hook throws and when the model step failed or was cancelled.
onError: async (error, { event }) => {
logger.warn({ requestId: event.requestId, error }, "agent step failed");
},
};saveConversation, loadConversation, saveMessages, weatherApi and logger stand for your own code.
A tool result may also carry contentParts: text and HTTPS image or PDF references the tool returned beside its result. Claude reads them inside the tool result; other protocols receive them in the user turn after the results.
With many tools, set deferLoading: true on the rarely used ones and send toolSearch: { mode: "native" }. Where the serving offering supports it, the provider searches the deferred tools itself (Claude BM25 tool search, OpenAI hosted tool_search) and its search history rides in nativeContent. Elsewhere every tool loads eagerly with no search tool. Admission records a tool_search_eager warning on an offering without it, and each attempt record says which happened in toolSearch (native or eager). Send the same tools, toolSearch and instructions on every step: a prompt that changes with the serving route invalidates the provider's prompt cache.
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.
3. Receive webhooks
Acknowledge quickly, then process in the background. The handler below works with any framework that uses the web Request/Response types (Hono, Next.js route handlers, Bun, Deno):
import { EventReceiver, redisEventLock } from "@lionhummer/constellation";
const receiver = new EventReceiver(client, hooks, {
// One process: leave this out (an in-memory lock is used).
// Several processes or pods: share the lock so only one works on an event at a time.
lock: redisEventLock((args) => redis.call(...args)), // ioredis; node-redis: (args) => redis.sendCommand(args)
onFailure: (eventId, error) => logger.warn({ eventId, error }, error.willRetry ? "retrying event" : "event will be re-sent"),
});
export async function webhook(request: Request): Promise<Response> {
let event;
try {
event = await client.webhooks.verify(request, process.env.CONSTELLATION_WEBHOOK_TOKEN!);
} catch {
return new Response(null, { status: 401 });
}
const run = await receiver.claim(event);
if (!run) return new Response(null, { status: 202 }); // another process is working on it
void run().then((outcome) => {
if (outcome.status === "answered" && outcome.ended) markConversationEnded(event.requestId, outcome.ended);
}, () => {}); // a failure leaves the event unanswered; Constellation re-sends it
return new Response(null, { status: 204 });
}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.
outcome.ended tells you when the request is over: completed or failed after your final answer, closed when it had already been cancelled or had expired. Requests that fail or are cancelled on the Constellation side arrive as model.failed and model.cancelled events. You never need to read the request back.
Recovery
A failure that may pass, such as a database lock timeout or a network error, is retried in the same process after 1, 3 and 9 seconds while it still holds the event (retryDelaysMs; [] turns this off). A re-send arriving meanwhile is answered 202. Your own code can throw PermanentEventError to skip these retries.
If your process dies while handling an event, you don't need a poller or a store to recover it. Constellation sends an unanswered model.completed event again after the response window (10 minutes by default; operators can lower it, for example to 2 minutes), up to 3 times. A re-sent event has the same id:
- while another process still holds the event's lock,
claimreturnsnulland you answer202. Constellation then checks again after another window without counting a re-send; - once the holder is gone, its lock expires (60 s by default, renewed while it works) and the re-send is processed from the start.
If you answer an event twice, for example because the reply to your first answer was lost, Constellation keeps the first answer and reports the second as already_accepted with bodyMatched: false. Nothing continues twice.
What happens to each event
Take the lock for the event. If another process holds it:
busy(answer202).Prepare the response:
- For
model.completed, run every tool call in parallel and callonStepComplete. To continue, callprepareRequest, which produces a continuation. To stop, produce anobserveresponse. - A tool call marked
invalid(its arguments were not valid JSON) is never run. Its result is an error frominvalidToolInputResultwithisError: true, so the model can correct the call on the next step, as the AI SDK does. - For
model.failedandmodel.cancelled, callonError(event.data)and produce anobserveresponse.
The response carries the idempotency key
event.id,inReplyTo: { requestId, eventId }and the telemetry recorded while preparing.- For
Submit it and release the lock:
| Server answer | Meaning | Result |
| --- | --- | --- |
| 200, outcome: "accepted" | Your response was accepted. A continuation starts the next step (scheduled: true). | answered |
| 200, outcome: "already_accepted" | This event was already answered by you. bodyMatched: false means the earlier answer differed and stands. | answered |
| 200, outcome: "event_closed" | The request was cancelled or expired before your continuation arrived. Your telemetry is still recorded (observationsRecorded). | answered, ended: "closed" |
| A permanent rejection (PERMANENT_RESPONSE_CODES, for example 400 bad_request) of a continuation | The continuation can never be accepted. | The SDK sends an observe response with the error instead, which ends the request as failed: ended: "failed" |
| 401, 403 | Key or scope problem. | onFailure runs and the call throws. The event stays unanswered and is re-sent. |
| Anything else: network error, timeout, 5xx, 429, 409 conflict, or an error thrown while preparing | Temporary. | onFailure runs with willRetry: true and the attempt is repeated after the next delay. After the last one, onFailure runs with willRetry: false, the call throws, and the event is re-sent. |
While processing, the SDK renews the lock every third of its lease. If renewal fails, it aborts its own work through signal, so a re-send taken over elsewhere doesn't run alongside it.
When your code fails
| What happened | Without onError | With onError |
| --- | --- | --- |
| A tool or hook throws | The event stays unanswered and is re-sent. 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 re-sent. |
| No handler for a tool the model called, or prepareRequest is missing when continuing | The request ends as failed (invalid_hook_configuration). | onError runs, as above. |
| The model step failed or was cancelled (model.failed, model.cancelled) | An observe response records your telemetry. | onError(event.data) runs first. |
Side effects and duplicates
Delivery is at least once. A crash after a tool ran but before the answer was accepted means the tool runs again when the event is re-sent. Constellation cannot make your side effects exactly-once; your code must:
- pass
effectIdto anything with side effects (payments, emails, writes). It is stable for one tool call of one event; - settle per-step billing or usage once per
event.id; - honour the
signalyou receive and put timeouts on external calls.
Live output
requests.streamEvents(id, { after, signal }) streams model.delta events (text and reasoning as it is generated), followed by each step's final event. A model.attempt.discarded event names an attempt whose output was withdrawn before its step is replayed: drop the deltas shown for that attemptId. It reconnects automatically and ends when the request has ended: the server closes the stream with an end frame after the request's last event, so the loop finishes without any state polling. 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.
Telemetry
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:
tools: {
search: async (args, { telemetry }) =>
telemetry.span("search.query", { index: "docs" }, async (span) => {
span.log("info", "querying index");
return searchIndex(args);
}),
},Work you do before the first model step, such as loading context and building the prompt, can travel with the request that starts the run: pass telemetry: { spans, logs } to requests.submit. Those spans belong to the request rather than a step: they sit at the top of the timeline at their own timestamps, before the first step (set stepId or parentSpanId to nest them elsewhere).
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.
Opening lines
Show a one-sentence opener while the real step is prepared. The operator configures its model and prompt in Constellation.
const opener = await client.openers.create({
idempotencyKey: `${turnId}`,
workspaceId, userId, externalId: threadId,
message,
history: recentTurns, // [{ role: "user" | "assistant", text }], oldest first
})
if (opener) void client.openers.sentence(opener.id).then((sentence) => {
if (sentence && !realOutputStarted) show(sentence)
})Start the real request without waiting for the opener. parseOpenerSentence(text) applies the same checks to text you already have.
Composers
Rewrite a turn's final message from the whole turn, so the answer leads instead of being buried mid-turn. The operator configures the model and prompt in Constellation. Render the turn as text yourself (the user's message, the agent's text, its tool calls and their results); Constellation does not parse it and sends only its end when it is longer than the operator's limit.
const composer = await client.composers.create({
idempotencyKey: `${turnId}`,
workspaceId, userId, externalId: threadId,
transcript, // the rendered turn
})
const composed = composer ? await client.composers.text(composer.id, { signal }) : null
const finalMessage = composed ?? agentFinalMessagecreate returns null when composers are turned off or denied for the caller. text returns the completed message trimmed, or null when the model failed, was cancelled or wrote nothing; keep the agent's own message then.
Telemetry queries
Ask aggregate questions without SQL. Fields, filters and metrics are typed per dataset.
// How many fireworks-direct attempts failed yesterday, and why?
const failures = await client.telemetry.query({
dataset: "attempts",
from: "2026-10-15T00:00:00Z", to: "2026-10-16T00:00:00Z",
where: { offeringId: { startsWith: "fireworks-direct:" }, status: "failed" },
groupBy: ["errorCode", "httpStatus"],
metrics: ["count", "uniq:requestId"],
})
// How often did a provider say "Response too long", per day?
const mentions = await client.telemetry.query({
dataset: "attempts",
from: "2026-10-01T00:00:00Z", to: "2026-10-16T00:00:00Z",
where: { errorMessage: { contains: "Response too long" } },
groupBy: ["day"],
})Usage and costs come from the permanent billing record (usage, one row per settled request, or usageAttempts, one row per provider attempt for provider/model splits). Any range works:
// Customer charges, supplier cost and margin per workspace and provider last month, without QA
const byProvider = await client.telemetry.query({
dataset: "usageAttempts",
from: "2026-09-01T00:00:00Z", to: "2026-10-01T00:00:00Z",
where: { workspaceId: { notIn: ["qa"] } },
groupBy: ["workspaceId", "providerId"],
metrics: ["uniq:requestId", "sum:customerCost", "sum:supplierCost", "sum:margin"],
limit: 10000,
})A scoped key only ever sees its workspace and user. Logs and usage need an unscoped key. Telemetry ranges are at most 92 days and results at most 1,000 rows; usage allows any range and 10,000 rows. Group by minute, hour, day, week or month for time series.
Limits and timeouts
| Setting | Default | Notes |
| --- | --- | --- |
| timeoutMs | 30 minutes | Deadline for the whole request, including your processing between steps. |
| 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. |
| 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. |
| maxProviderAttempts | 32 | Provider attempts per step, including fallbacks (1–256). |
| toolConcurrency hook option | 8 | Parallel tool calls per event (1–128). |
| Telemetry per response | 512 spans, 128 logs, 1 MiB | Larger batches are trimmed by the SDK. |
| Late observations | 24 hours | After the request ends, observe responses are accepted for 24 hours. |
| 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. |
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.
Client reference
| Method | What it does |
| --- | --- |
| models.list(scope) | Models this key can use, with supported parameters. No provider call. |
| models.resolve(input) | Which providers would serve a model, without calling them. |
| requests.submit(input) | Start a request, continue it (inReplyTo), or stop it (action: "observe"). |
| requests.preview(input) | Validate a complete input and show routing, without calling a provider. |
| requests.cancel(id) | Cancel a request. |
| openers.create(input), openers.sentence(id) | Start an opening line for a just-sent message (null when openers are turned off), then wait for its sentence (null when skipped or failed). |
| composers.create(input), composers.text(id) | Start a composed final message from a turn transcript (null when composers are turned off), then wait for its text (null when it failed or is empty). |
| requests.getState(id) | Committed state: status, steps, attempt outcomes, usage and cost. For inspection and support tools; event processing never needs it, because boundary events carry the step number, step input and request identity. |
| requests.getSummary(id), requests.get(id) | Request detail, without or with spans and logs. |
| requests.list(query) | Page through requests, e.g. by externalId or status. |
| requests.listEvents(id, after), requests.streamEvents(id) | Replay events after a sequence number, or stream them live. |
| requests.listRecords(id, kind) | Page steps, attempts, spans or logs. |
| requests.getTimeline(id), requests.getTimings(id) | Trace and timing breakdown. |
| requests.getHttpExchange(id, attemptId), requests.getHttpBody(...) | The provider HTTP call captured for one attempt. |
| webhooks.verify(request, token) | Authenticate and parse an incoming webhook. |
| telemetry.query(query) | Count, group and aggregate attempts, requests, logs or permanent usage, e.g. failed attempts per offering and error code, or cost per workspace and provider. Needs telemetry permission; the key's scope always applies. |
| telemetry.* | Read-only reporting. Returns metadata only unless you pass include: "content". Needs telemetry permission. |
| management.registry.* | Edit the model registry. Needs management permission. |
| discover() | The OpenAPI document for exactly what this key may call. |
Exports for event processing: EventReceiver, processEvent (inline: lock, prepare, submit), prepareResponse (build a response without submitting it), MemoryEventLock, redisEventLock, invalidToolInputResult, ClientTelemetry, PermanentEventError, HookConfigurationError, PERMANENT_RESPONSE_CODES, ConstellationError and uuid7.
Troubleshooting
| Symptom | What to check |
| --- | --- |
| models.list is empty | The key's workspace/user scope, an access rule for that workspace, and an enabled model and offering. |
| terminal: "notify" is rejected | The execution key has no webhook URL configured. |
| The same webhook keeps arriving | Your endpoint returned non-2xx or took longer than the delivery timeout (5 s by default). Answer after receiver.claim and process in the background. |
| 400 on a continuation | Model, workspace, user, externalId, plan, events or timeoutMs differ from the first request, or the message history is malformed. |
| 409 response_conflict | Your code produced a different response for an event that was already answered. Answers must come from the saved response. |
| Tools ran twice | Expected after a crash. Make side effects idempotent with effectId. |
| A step finished with outcome: "event_closed" | The request was cancelled or its timeoutMs passed while your code was working. |
| Client spans missing from the timeline | Telemetry is sent with the response. Check that processEvent reached the server. |
| Request expired while tools were running | timeoutMs covers your processing time too. Raise it on the first request. |
More detail: application integration, HTTP API and the OpenAPI document.
