SDK behavior in detail
The contracts @werft-ai/sdk keeps for retries, errors, the event stream, turn waiting, close and push approval
This page describes how @werft-ai/sdk wraps the rules of the HTTP API. How to use it is in Install the SDK and Client settings and errors; this page covers only the finer contracts underneath. The rules of the HTTP API itself (scopes, Idempotency-Key, error codes, the event stream) are defined by the API use-case guide. Everything below is what the code in packages/sdk confirms.
Client and types
apiKeyis sent only asAuthorization: Bearer <key>. It appears in no error message, ininspector in JSON.- Types are taken from the types generated from OpenAPI, with names added. The enums in response types are open unions, so a new value from the server (a new state, a new error code) does not break the types or the code. Request types are closed.
sessions.listandturns.listgive the first page when youawaitthem and every item when youfor awaitthem.page.nextPage()isnullon the last page.- The caller passes
expected_revisionfor pause, resume, terminate and close. The SDK does not read and fill it in, and it does not resend automatically onREVISION_CONFLICT. limits.get(GET /v1/limits) can be read only by the signed-in workspace owner. Calling it with an API key returns 403.
Retries and Idempotency-Key
- It retries on:
retryable: truein the response body, a 502 or 504 that is not an API error body (a proxy response), and a connection failure or attempt timeout where no response was read.- After a connection failure, a timeout, or a 502 or 504 that is not an API error body, it resends only GET requests and commands that have an
Idempotency-Key.openPullRequestandcommentPullRequesthave no key and the server may already have run them, so they are not resent.
- After a connection failure, a timeout, or a 502 or 504 that is not an API error body, it resends only GET requests and commands that have an
- It does not retry on: any 409 and 500. Any other API error is resent only when
retryablein the response body istrue; a 4xx withretryable: trueis retried too (429 included). - Wait time: if
Retry-Afteris present, that value is used. If it exceedsmaxRetryDelayMs(60 seconds by default), the call fails at once without waiting and puts the value in the error'sretryAfterMs. Without it, the wait is random withinmin(0.5s·2^n, 8s, maxRetryDelayMs). waitForTurn,waitForReceiptandwaitForPushRequestpass a retryable read failure on to the next check. If that error hasRetry-After, the next check comes after that time, and an incoming event does not bring it forward.- A command that needs a key settles on one key per call (the
idempotencyKeyyou passed, or else a new value) and sends the same key on every retry of that call. When the same request is sent again with the same key, the server runs the command once. - The default key of
sessions.requests.answerisanswer:<request_id>. A request has only one answer. - To keep retrying after a process restart, save the error's
idempotencyKeyand pass it back as{ idempotencyKey }.
Errors
There is one error class per way of failing. Server errors are told apart by code.
| Class | When | Main fields |
|---|---|---|
APIError | The server answered with an error | status, code (the server's error.code as is), retryable, requestId, details, retryAfterMs |
ConnectionError | No response was read. It is unknown whether the command ran | code: "NETWORK_ERROR" |
TimeoutError | An attempt exceeded timeoutMs | code: "TIMEOUT" |
AbortedError | The caller's signal aborted | name: "AbortError", cause is the signal's reason |
TurnBlockedError | Only in waitForTurn. The session is paused, stopped, recovery_required or closed, so the turn cannot end on its own | details.admission_state |
All extend WerftError and carry attempts. The errors of a command that has a key, abort included, carry idempotencyKey. The TypeError that header validation throws before the key is settled has none.
import { isApiError } from "@werft-ai/sdk";
try {
await werft.sessions.pause(id, { expected_revision: revision });
} catch (error) {
if (isApiError(error, "REVISION_CONFLICT")) {
// Someone else changed the session. Read it again and decide.
}
throw error;
}Event stream
const stream = werft.sessions.events.stream(sessionId, { lastEventId, signal });
for await (const event of stream) {
console.log(event.kind, event.turn_id, event.payload);
}
// Save stream.lastEventId and another process can receive from right after it.- When the connection drops, it reconnects with
Last-Event-IDand receives from right after that event. - It does not close by itself when a turn or the session ends. End it with
signalorstream.close(). - 400, 401, 403, 404 and 410 stop the repetition and throw an
APIError. - When more than 10,000 events are unread, it hands over all the events it already holds and then throws a
WerftErrorwithcode: "STREAM_OVERFLOW". The events it handed over are continuous, so reopening withstream.lastEventIdmisses none. - Opening without
lastEventIdreceives the whole session again from the start. Each owner can open only a limited number of streams (429 beyond that).
Waiting for a turn and answering permission requests
waitForTurn checks over REST every 2 seconds until the turn ends. In the same loop it passes the session's pending requests to your handlers.
import { type PendingRequestHandlers, waitForTurn } from "@werft-ai/sdk";
const approvals: PendingRequestHandlers = {
permission(request) {
const command = (request.input as { command?: unknown } | null)?.command;
if (request.tool === "Bash" && command === "git status") return { action: "allow" };
return { action: "deny", reason: "This script only allows: git status" };
},
};
const turn = await waitForTurn(werft, sessionId, turnId, {
signal: AbortSignal.timeout(30 * 60_000),
onRequest: approvals,
onAnswer: (outcome) => console.log(outcome.request_id, outcome.outcome),
events: { onEvent: (event) => console.log(event.kind) }, // optional: wake up as soon as an event arrives
});
console.log(turn.status); // "failed" is a value, not an error- The turn counts as ended when
statusofGET …/turns/{turn_id}is terminal (completed,failed,interrupted,cancelled,outcome_unknown) orended_atis set. Theresultevent is not the completion check. - The handler is a security boundary. Allow only an exactly matching input and deny the rest. A denial needs a
reasonby type. - If the handler returns
undefined, or there is no handler for that kind, the request stays open for a person in the console or Slack. - The outcome of an answer (
AnswerOutcome):accepted(stored; the receipt may fail later),left_open,too_late(REQUEST_EXPIRED,REQUEST_STALE),conflict(another answer was stored first). - To answer, the key needs
sessions:approve. Without it the wait fails with 403. - The only deadline is
signal. If you wait with a question left open, you wait until a person answers. - Turning on
eventsuses one more stream. If the stream fails, polling continues.
To handle requests once, use answerPending(werft, sessionId, approvals).
Closing a session (close)
This ends a session whose work is done. Unlike terminate, nothing is killed. The running turn ends, the checkpoint is committed, and then the session becomes closed. The scope is sessions:control.
import { closeBlocked, closeResult, waitForReceipt } from "@werft-ai/sdk";
const { revision } = await werft.sessions.get(sessionId);
const accepted = await werft.sessions.close(sessionId, {
expected_revision: revision,
reason: "script finished",
});
const receipt = await waitForReceipt(werft, accepted.receipt_id, {
signal: AbortSignal.timeout(10 * 60_000),
});
console.log(receipt.status, closeResult(receipt));
// { resulting_admission_state: "closed", checkpoint_revision: 3, cancelled_turn_count: 0 }- While closing, the session is
closing(isClosing(session)). A message and most controls sent then get 409SESSION_CLOSING. Terminate is accepted while closing and overtakes the close; the close receipt fails withCONTROL_SUPERSEDED. - If the drain takes more than 60 seconds, the session
attentionbecomesCLOSE_BLOCKED(closeBlocked(session)gives that reason). With an open question or approval request, close waits until you answer it or interrupt the turn. So givewaitForReceipta deadline throughsignal. - Input that was waiting is cancelled when the close commits (
cancelled_turn_count). A message or control after the close gets 409SESSION_CLOSED. - An agent that closes its own session inside its own turn does not wait for the receipt, because the drain waits for that turn to end.
Push approval
import { waitForPushRequest } from "@werft-ai/sdk";
const push = await werft.sessions.pushRequests.create(sessionId);
// After reviewing push.git_commit, with the approval key:
await approver.sessions.pushRequests.approve(sessionId, push.id, {
git_commit: push.git_commit,
});
const done = await waitForPushRequest(werft, sessionId, push.id, { signal });
if (done.state === "pushed")
await approver.sessions.pushRequests.openPullRequest(sessionId, push.id);The SDK does not handle CHECKPOINT_MOVED specially. Even when you receive it, it does not read again and approve again, so approve only the commit you reviewed.
Versions
- The SDK version (
SDK_VERSION) is the package's own semver. See the published versions withnpm view @werft-ai/sdk dist-tags. API_SPEC_VERSIONis theinfo.versionof the spec the types were generated from. It is not compared with the server version at runtime.- The SSE envelope reads only
schema_version1 (SUPPORTED_SSE_SCHEMA_VERSIONS).
Where the rest is
| Content | Location |
|---|---|
| Installing, creating a client with a server address and an API key | Install the SDK |
| The first script: create a session, answer a permission request, close | Create your first session, Permission requests and closing |
| Client options, a separate approval key, wrapped APIs, examples in the repository | Client settings and errors |
| Receiving the event stream | Receiving a stream |
| Methods for sessions, messages and approvals | Sessions, Messages, Approvals and push |
| Samples by use case | SDK samples |