# Caitlyn developer docs {#_top} Build on Caitlyn from your own code: ask an agent a question and stream its cited answer, load documents into a knowledge base, and change an agent safely, through the Caitlyn v3 API and its SDKs for TypeScript, Python, Go and Rust. > **Preview.** The v3 API is being built. The SDKs and this reference describe the whole of it, but a Caitlyn > release serves only [GetAgentVersion](/reference/get-agent-version/) so far; every other operation's page says > it is not available yet. Expect changes before version 1.0. ## Start here - [Getting started](/getting-started/): install an SDK, create a client for your workspace and make a first call. - [Authentication](/authentication/): the six ways a client signs in, and which to use. - [Sign in from a browser](/guides/sign-in-from-a-browser/): a web app calling Caitlyn as the person using it. - How the SDKs behave: [errors](/errors/), [retries](/retries/), [streaming](/streaming/), [pagination](/pagination/) and [telemetry](/telemetry/). ## The API reference One reference covers every SDK. Each operation's page shows the call in TypeScript, Python, Go and Rust (pick a language once and every page follows), the request and response members, the permission it checks, its errors, and complete, runnable examples. The examples are compiled against every SDK on each change, so they match the SDKs you install. - Operations: [versions](/reference/get-agent-version/), [conversations](/reference/send-message/), [documents](/reference/list-documents/) and [uploads](/reference/create-upload/). - [Types](/reference/types/agent-configuration/): the objects the operations take and return. - [Error codes](/errors/NOT_FOUND/): one page per code, at the address each error's `type` names. ## The SDKs | Language | Package | Runs on | | --- | --- | --- | | TypeScript | `@caitlyn-ai/sdk` on npm | Node 20 and later, Bun, Deno, browsers and edge runtimes | | Python | `caitlyn` (installed from the GitHub repository until it is on PyPI) | Python 3.12 and later | | Go | `github.com/caitlyn-ai/caitlyn-go` (from the GitHub repository until the module path is published) | Go 1.23 and later | | Rust | `caitlyn-sdk` (from the GitHub repository until it is on crates.io) | Rust with tokio | All four are generated from the same model of the API, share one version number and behave the same way: the same sign-in modes, retries, errors and telemetry. None of them depends on an AWS SDK. The source is at [github.com/caitlyn-ai/sdk](https://github.com/caitlyn-ai/sdk). ## For AI agents and tools Every page is also Markdown: add `.md` in place of the trailing slash (`/getting-started.md`). The index of them all is [`/llms.txt`](/llms.txt), and [`/llms-full.txt`](/llms-full.txt) has every page in one file. # Getting started {#_top} Install an SDK, create a client for your workspace, and read an agent's draft. You need a Caitlyn workspace and a credential for it. The quickest is a personal access token from your workspace (it starts `cait-pat-`); put it in the `CAITLYN_TOKEN` environment variable. [Authentication](/authentication/) covers the other ways to sign in, which are what you use for anything other people run. ## Install ```sh tab="TypeScript" npm install @caitlyn-ai/sdk ``` ```sh tab="Python" # Not on PyPI yet: install both packages from the same release tag pip install "caitlyn-core @ git+https://github.com/caitlyn-ai/sdk@v0.7.0#subdirectory=python/caitlyn-core" \ "caitlyn @ git+https://github.com/caitlyn-ai/sdk@v0.7.0#subdirectory=python/caitlyn" ``` ```sh tab="Go" # The module path does not resolve yet: build against a copy of the release git clone --depth 1 --branch v0.7.0 https://github.com/caitlyn-ai/sdk caitlyn-sdk go mod edit -require github.com/caitlyn-ai/caitlyn-go@v0.0.0 \ -replace github.com/caitlyn-ai/caitlyn-go=./caitlyn-sdk/go go mod tidy ``` ```toml tab="Rust" # Cargo.toml. Not on crates.io yet: depend on the release tag [dependencies] caitlyn-sdk = { git = "https://github.com/caitlyn-ai/sdk", tag = "v0.7.0" } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } ``` `v0.7.0` is the latest release; the [tags](https://github.com/caitlyn-ai/sdk/tags) list every release. ## Make a first call Create a client for your workspace, then call an operation. This reads the draft version of an agent: its configuration, and the ETag you would send to change it. Put your own agent's ID in place of `GHT7YRWXPL`. ```ts tab="TypeScript" import { Caitlyn } from '@caitlyn-ai/sdk'; const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! }, }); // Read the draft of an agent: its configuration and its ETag const draft = await caitlyn.versions.get({ agentId: 'GHT7YRWXPL', version: 'draft' }); console.log(draft.configuration.instructions); ``` ```python tab="Python" import os from caitlyn import Caitlyn from caitlyn.auth import Token caitlyn = Caitlyn(workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"])) # Read the draft of an agent: its configuration and its ETag draft = caitlyn.versions.get(agent_id="GHT7YRWXPL", version="draft") print(draft.configuration.instructions) ``` ```go tab="Go" import ( "context" "fmt" "log" "os" caitlyn "github.com/caitlyn-ai/caitlyn-go" ) func main() { ctx := context.Background() client, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) if err != nil { log.Fatal(err) } // Read the draft of an agent: its configuration and its ETag draft, err := client.Versions.Get(ctx, &caitlyn.GetAgentVersionInput{AgentID: "GHT7YRWXPL", Version: "draft"}) if err != nil { log.Fatal(err) } fmt.Println(draft.Configuration.Instructions) } ``` ```rust tab="Rust" use caitlyn_sdk::{Auth, Client, GetAgentVersionInput}; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; // Read the draft of an agent: its configuration and its ETag let draft = client .versions() .get(GetAgentVersionInput { agent_id: "GHT7YRWXPL".into(), version: "draft".into() }) .await?; println!("{}", draft.configuration.instructions); Ok(()) } ``` [GetAgentVersion](/reference/get-agent-version/) has the whole result, and what each error means. ## The workspace `workspace` is your workspace's hostname, such as `your-company.getcaitlyn.ai`. It decides two things, the way a region does in the AWS SDKs: where requests go (`https:///api`), and the audience every token the client gets is bound to. A token for one workspace is refused by every other. To send requests somewhere else, such as a proxy or a Caitlyn running on your machine, set the client's `endpoint` option. The token audience still comes from the workspace. ## Versions and older servers Each SDK knows which Caitlyn release introduced each operation and member. Once a client has had a response from your workspace, it knows the server's version (the `Caitlyn-Api-Version` header), and it refuses to call an operation, or send a member, that the server is too old for: it raises `NotSupportedByServer` (Go `NotSupportedByServerError`, Rust `Error::NotSupportedByServer`) before sending anything. Each reference page says which release an operation needs. The API only grows: a newer server adds operations, members, enum values and events, and never removes or renames them. The SDKs keep what they do not know: unknown members are in each result's `raw`, an unknown enum value is kept as its string, and an unknown event is skipped. So an older SDK keeps working against a newer server. ## Next - [Authentication](/authentication/): sign in as a service, as a person, or as a workload on AWS or in CI. - [Errors](/errors/): what each failure looks like, and which ones to handle. - [Streaming](/streaming/): ask an agent a question and stream the answer. # Authentication {#_top} A client signs in one of six ways, chosen by its `auth` option. Every way except `token` and `session` ends in the same thing: a short-lived Caitlyn access token, which the client caches, renews before it expires and sends as `Authorization: Bearer `. You never handle that token yourself. | Mode | Use it for | You give it | | --- | --- | --- | | [`token`](#a-personal-access-token) | Scripts you run yourself, and trying things out | A personal access token | | [`clientCredentials`](#a-service-client-credentials) | A service or integration acting as itself | An API client's ID and secret | | [`oauth`](#a-person-oauth) | An app acting as the person using it | An API client's ID (and secret, on a server), a redirect URI | | [`awsIam`](#a-workload-on-aws-awsiam) | A workload on AWS (Lambda, ECS, AgentCore), with no stored secret | Nothing: the workload's own AWS credentials | | [`workloadIdentity`](#a-ci-job-or-other-workload-workloadidentity) | A CI job, or another workload with an OpenID Connect token | A function returning the token | | [`session`](#the-caitlyn-app-session) | The Caitlyn app itself, and tools driving a local copy of it | The app's session cookie | Every token is for one workspace: its audience is `https:///api`, the workspace the client was created for, and every other workspace refuses it. ## A personal access token A personal access token acts as you, with your permissions, until it expires or you revoke it. It is sent as it is and never renewed. Use it for your own scripts and for trying the API; never put one in code other people run. ```ts tab="TypeScript" const script = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! }, // a personal access token, cait-pat-… }); ``` ```python tab="Python" script = Caitlyn( workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"]), # a personal access token, cait-pat-… ) ``` ```go tab="Go" script, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) // a personal access token, cait-pat-… ``` ```rust tab="Rust" let script = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; ``` ## A service: client credentials A registered API client signs in with its ID and secret and acts as itself, with the scopes it was given. The client asks for a new token when the old one is due; there is no refresh token. Keep the secret in a secret store, never in code. ```ts tab="TypeScript" const service = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'clientCredentials', clientId: process.env.CAITLYN_CLIENT_ID!, clientSecret: process.env.CAITLYN_CLIENT_SECRET!, scopes: ['api:read'], }, }); ``` ```python tab="Python" service = Caitlyn( workspace="your-company.getcaitlyn.ai", auth=ClientCredentials( client_id=os.environ["CAITLYN_CLIENT_ID"], client_secret=os.environ["CAITLYN_CLIENT_SECRET"], scopes=["api:read"], ), ) ``` ```go tab="Go" service, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.ClientCredentials{ ClientID: os.Getenv("CAITLYN_CLIENT_ID"), ClientSecret: os.Getenv("CAITLYN_CLIENT_SECRET"), Scopes: []string{"api:read"}, }) ``` ```rust tab="Rust" let service = Client::new( "your-company.getcaitlyn.ai", Auth::client_credentials(std::env::var("CAITLYN_CLIENT_ID")?, std::env::var("CAITLYN_CLIENT_SECRET")?) .scopes(["api:read"]), )?; ``` ## A person: OAuth An app signs a person in with the OAuth authorisation code flow and PKCE, then acts as that person. The client builds the sign-in address; you send the person there and keep the `state` and the PKCE verifier until Caitlyn sends them back to your redirect URI. ```ts tab="TypeScript" const asPerson = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'oauth', clientId: process.env.CAITLYN_CLIENT_ID!, clientSecret: process.env.CAITLYN_CLIENT_SECRET, // only on a server; a browser app has none redirectUri: 'https://app.example.com/caitlyn/callback', scopes: ['api:write'], tokenStore: encryptedTokenStoreFor(userId), // { load, save, clear }; default: in memory }, }); // Send the person to `url`, and keep `state` and `codeVerifier` for the callback const { url, state, codeVerifier } = await asPerson.auth.authorizationUrl(); ``` ```python tab="Python" as_person = Caitlyn( workspace="your-company.getcaitlyn.ai", auth=OAuth( client_id=os.environ["CAITLYN_CLIENT_ID"], client_secret=os.environ.get("CAITLYN_CLIENT_SECRET"), # a confidential client on a server redirect_uri="https://app.example.com/caitlyn/callback", scopes=["api:write"], token_store=encrypted_token_store_for(user_id), # load, save, clear; default: in memory ), ) # Send the person to start.url, and keep start.state and start.code_verifier for the callback start = as_person.auth.authorization_url() ``` ```go tab="Go" asPerson, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.OAuth{ ClientID: os.Getenv("CAITLYN_CLIENT_ID"), ClientSecret: os.Getenv("CAITLYN_CLIENT_SECRET"), // a confidential client on a server RedirectURI: "https://app.example.com/caitlyn/callback", Scopes: []string{"api:write"}, TokenStore: encryptedTokenStoreFor(userID), // default: in memory }) if err != nil { return err } // Send the person to start.URL, and keep start.State and start.CodeVerifier for the callback start, err := asPerson.Auth().AuthorizationURL(ctx, caitlyn.AuthorizationOptions{}) ``` ```rust tab="Rust" let as_person = Client::new( "your-company.getcaitlyn.ai", Auth::oauth(std::env::var("CAITLYN_CLIENT_ID")?) .client_secret(std::env::var("CAITLYN_CLIENT_SECRET")?) // a confidential client on a server .redirect_uri("https://app.example.com/caitlyn/callback") .scopes(["api:write"]) .token_store(Arc::new(MemoryTokenStore::new(None))), // your own encrypted store on a server )?; // Send the person to start.url, and keep start.state and start.code_verifier for the callback let start = as_person.auth().authorization_url(None, None, None)?; ``` Then, on the redirect URI, hand the client the code. It checks the state, exchanges the code and saves the tokens to the token store: ```ts tab="TypeScript" declare const code: string; declare const returnedState: string; await asPerson.auth.handleCallback({ code, state: returnedState, expectedState: state, codeVerifier }); ``` ```python tab="Python" as_person.auth.handle_callback( code=code, state=returned_state, expected_state=start.state, code_verifier=start.code_verifier, ) ``` ```go tab="Go" _, err = asPerson.Auth().HandleCallback(ctx, caitlyn.Callback{ Code: code, State: returnedState, ExpectedState: start.State, CodeVerifier: start.CodeVerifier, }) ``` ```rust tab="Rust" as_person .auth() .handle_callback(Callback { code, state: Some(returned_state), expected_state: Some(start.state), code_verifier: start.code_verifier, ..Default::default() }) .await?; ``` - **Refresh is automatic.** Refresh tokens are single use: each refresh returns a new one, which the client saves to the token store. A refresh token the server refuses fails the call with the sign-in error `invalid_grant` and clears the store: sign the person in again. - **The token store** keeps the tokens between processes. On a server, keep them encrypted, one store per person. Without one, they live in memory for the life of the client. - **Signing out** (`auth.signOut()`, Python `auth.sign_out()`, Go `Auth().SignOut`, Rust `auth().sign_out()`) revokes the refresh token and clears the store. - **In a browser**, use `oauth` without a client secret. [Sign in from a browser](/guides/sign-in-from-a-browser/) covers both patterns, and what the TypeScript SDK refuses in a page. ## A workload on AWS: awsIam > **Not live yet.** The SDKs have this mode, but Caitlyn does not accept workload sign-in yet. A workload on AWS signs in with its own IAM role: no secret is stored anywhere. The client asks AWS STS (`GetWebIdentityToken`, on the regional endpoint) for a token AWS signs, and exchanges it for a Caitlyn token. The SDKs sign that one request themselves, so no AWS SDK is needed. ```ts tab="TypeScript" const worker = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'awsIam', region: 'ap-southeast-2' }, // the workload's own AWS credentials }); ``` ```python tab="Python" worker = Caitlyn( workspace="your-company.getcaitlyn.ai", auth=AwsIam(region="ap-southeast-2"), # the workload's own AWS credentials ) ``` ```go tab="Go" worker, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.AWSIAM{Region: "ap-southeast-2"}) // the workload's own AWS credentials ``` ```rust tab="Rust" let worker = Client::new("your-company.getcaitlyn.ai", Auth::aws_iam().region("ap-southeast-2"))?; ``` - **Credentials** come from `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` (as Lambda sets them), then the container credentials endpoint (ECS, Fargate, AgentCore). For anything else, such as profiles or single sign-on, pass a credentials provider. This is the only mode that reads `AWS_*` variables. - **The region** is the option, then `AWS_REGION`, then `AWS_DEFAULT_REGION`. - **Setting up:** outbound identity federation must be turned on in the AWS account, and the workspace needs an API client that trusts the account's issuer and the role. ## A CI job or other workload: workloadIdentity > **Not live yet.** The SDKs have this mode, but Caitlyn does not accept workload sign-in yet. A workload with an OpenID Connect token from an issuer the workspace trusts, such as a GitLab CI job's `id_tokens`, exchanges that token for a Caitlyn one. The function you give is called at every sign-in, because Caitlyn accepts each token once, only for the exact audience `https:///api`, and only before it expires. ```yaml # .gitlab-ci.yml publish: id_tokens: CAITLYN_ID_TOKEN: aud: https://your-company.getcaitlyn.ai/api ``` ```ts tab="TypeScript" const ciJob = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'workloadIdentity', token: () => process.env.CAITLYN_ID_TOKEN! }, // GitLab CI id_tokens }); ``` ```python tab="Python" ci_job = Caitlyn( workspace="your-company.getcaitlyn.ai", auth=WorkloadIdentity(token=lambda: os.environ["CAITLYN_ID_TOKEN"]), # GitLab CI id_tokens ) ``` ```go tab="Go" ciJob, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.WorkloadIdentity{ Token: func(context.Context) (string, error) { return os.Getenv("CAITLYN_ID_TOKEN"), nil }, // GitLab CI id_tokens }) ``` ```rust tab="Rust" let ci_job = Client::new("your-company.getcaitlyn.ai", WorkloadIdentity::from_env("CAITLYN_ID_TOKEN"))?; ``` ## The Caitlyn app: session `session` sends the Caitlyn app's own session cookie instead of a token, and an `Origin` header on every write. It is for the Caitlyn app itself and for tools that drive a local copy of it; an integration uses one of the other modes. ```ts tab="TypeScript" const localApp = new Caitlyn({ workspace: 'caitlyn.lndo.site', auth: { mode: 'session', cookie: sessionCookie, origin: 'https://caitlyn.lndo.site' }, }); ``` ```python tab="Python" local_app = Caitlyn( workspace="caitlyn.lndo.site", auth=Session(cookie=session_cookie, origin="https://caitlyn.lndo.site"), ) ``` ```go tab="Go" localApp, err := caitlyn.NewClient("caitlyn.lndo.site", caitlyn.Session{Cookie: sessionCookie, Origin: "https://caitlyn.lndo.site"}) ``` ```rust tab="Rust" let local_app = Client::new("caitlyn.lndo.site", Auth::session(session_cookie).origin("https://caitlyn.lndo.site"))?; ``` ## When a token is refused - **One token, renewed early.** A client keeps one access token and renews it a minute before it expires (or halfway through a life shorter than two minutes). However many calls need a new token at once, the client makes one request for it, and they all wait for that. - **One more try after a 401.** In the `clientCredentials`, `oauth`, `awsIam` and `workloadIdentity` modes, a 401 makes the client drop its token, get a new one and send the call once more, whatever the operation. A second 401 is raised as the [`UNAUTHENTICATED`](/errors/UNAUTHENTICATED/) error. In `token` and `session` mode a 401 is raised at once. - **Sign-in errors** are a separate class from the API's errors: `CaitlynAuthError` in TypeScript and Python, `*caitlyn.AuthError` in Go, `Error::Auth` in Rust. `code` is the OAuth error (`invalid_client`, `invalid_grant`), or one of the SDK's own: `sign_in_required` (`oauth` with no tokens yet), `invalid_state` (the callback's state does not match), `invalid_request` (a missing redirect URI or region), `aws_identity_failed` (`awsIam` could not get its AWS token), and in a browser `not_allowed_in_browser`. # Errors {#_top} Every error the API returns is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details object, and every SDK raises it as a typed error you can tell apart by HTTP status and by code. ```json { "type": "https://build.caitlyn.ai/errors/NOT_FOUND", "title": "Not found", "status": 404, "code": "NOT_FOUND", "detail": "No knowledge base KBNOPE0000", "requestId": "01JC9Q2W7R4M0H2T6N1S8V3B5X" } ``` ## Handling errors ```ts tab="TypeScript" try { await caitlyn.versions.get({ agentId, version: 'v99' }); } catch (err) { if (err instanceof NotFoundError) { console.log('No such version'); } else if (err instanceof ValidationError) { for (const e of err.errors ?? []) console.error(e.pointer, e.code, e.detail); } else if (err instanceof CaitlynConnectionError) { console.error('No response from Caitlyn:', err.cause); // after the SDK's retries } else if (err instanceof CaitlynError) { // Any other status. code is the stable product code; quote requestId to support. console.error(err.status, err.code, err.detail, err.requestId); if (err.code.startsWith('HTTP_')) console.error(err.rawBody); // not a Caitlyn error body: a proxy's page, say } else { throw err; } } ``` ```python tab="Python" try: caitlyn.versions.get(agent_id=agent_id, version="v99") except NotFoundError: print("No such version") except ValidationError as err: for e in err.errors: # one per member or parameter at fault print(e.pointer, e.code, e.detail) except CaitlynConnectionError as err: print("No response from Caitlyn:", err.__cause__) # after the SDK's retries except CaitlynError as err: # Any other status. code is the stable product code; quote request_id to support. print(err.status, err.code, err.detail, err.request_id) if err.code.startswith("HTTP_"): print(err.raw_body) # not a Caitlyn error body: a proxy's page, say ``` ```go tab="Go" _, err := client.Versions.Get(ctx, &caitlyn.GetAgentVersionInput{AgentID: "GHT7YRWXPL", Version: "v99"}) var notFound *caitlyn.NotFoundError var invalid *caitlyn.ValidationError var noResponse *caitlyn.ConnectionError var apiErr *caitlyn.Error switch { case err == nil: case errors.As(err, ¬Found): fmt.Println("No such version") case errors.As(err, &invalid): for _, e := range invalid.Errors { fmt.Println(e.Pointer, e.Code, e.Detail) } case errors.As(err, &noResponse): fmt.Println("No response from Caitlyn:", noResponse.Cause) // after the SDK's retries case errors.As(err, &apiErr): // Any other status. Code is the stable product code; quote RequestID to support. fmt.Println(apiErr.Status, apiErr.Code, apiErr.Detail, apiErr.RequestID) if apiErr.RawBody != "" && apiErr.Title == "" { fmt.Println(apiErr.RawBody) // not a Caitlyn error body: a proxy's page, say } default: log.Fatal(err) } ``` ```rust tab="Rust" let input = GetAgentVersionInput { agent_id: "GHT7YRWXPL".into(), version: "v99".into() }; match client.versions().get(input).await { Ok(version) => println!("{}", version.name), Err(Error::NotFound(_)) => println!("No such version"), Err(Error::Validation(err)) => { for e in &err.errors { println!("{:?} {:?} {:?}", e.pointer, e.code, e.detail); } } Err(Error::Connection(err)) => println!("No response from Caitlyn: {err}"), // after the SDK's retries Err(err) => { // Any other error. code() is the stable product code; quote request_id() to support. println!("{:?} {:?} {:?}", err.status(), err.code(), err.request_id()); return Err(err); } } ``` - **The class follows the HTTP status**, whether or not the operation lists that error: a 404 is always `NotFoundError`. A status no error class has (a 405, a proxy's 502) is the base class, `CaitlynError` (Go `*caitlyn.Error`, Rust `Error::Api`). - **Match on `code`, not on the message.** `code` is stable: once shipped, a code keeps its meaning and is never renamed. Several codes can share a status; each has [its own page](#error-codes). - **Quote `requestId`** (Python and Rust `request_id`, Go `RequestID`) to Caitlyn support. It is also in the `Caitlyn-Request-Id` header of every response. ## The fields | Field | What it holds | | --- | --- | | `status` | The HTTP status of the response, always (a `status` in the body is ignored for this). | | `code` | The stable code, for example `NOT_FOUND`, or `HTTP_` when the body is not a Caitlyn error. | | `title`, `detail`, `type` | A short summary, what went wrong this time, and the code's page here. | | `requestId` | The request's identifier. | | `retryAfterSeconds` | On 429 and 503: how long to wait, from `Retry-After`. | | `errors` | On 400 `VALIDATION_FAILED`: one entry per member or parameter at fault. | | `rawBody` | The response body as received, as text, at most its first 8 KiB (Python and Rust `raw_body`, Go `RawBody`). | | `rawBodyTruncated` | True when the body was longer than 8 KiB and `rawBody` holds only its start. | ## When the body is not a Caitlyn error Something in front of Caitlyn can answer instead of it: a load balancer's `502` page, or an HTML `404` for an address that does not exist. That is still the typed error for its status, never a parsing error. Its `code` is `HTTP_` (`HTTP_502`), `title`, `detail` and `type` are empty, and the body is in `rawBody`, so you can see what actually answered. ## When there is no response A call that gets no response at all (a refused or reset connection, a DNS or TLS failure, a timeout before the response headers), or whose connection fails while the body is being read, fails with the connection error once the SDK's [retries](/retries/) are spent: | Language | Error | It is also | The network error | | --- | --- | --- | --- | | TypeScript | `CaitlynConnectionError` | a `CaitlynError` | `cause` | | Python | `CaitlynConnectionError` | a `CaitlynError` | `__cause__` | | Go | `*caitlyn.ConnectionError` | an `*caitlyn.Error` (`errors.As` finds either) | `Cause`, and `Unwrap()` | | Rust | `Error::Connection` | `status()` and `code()` read it like any other | `source()` | Its `status` is 0 and its `code` is `CONNECTION_ERROR`. Cancelling a call yourself is never a connection error: you get your language's own cancellation error. ## Errors the SDK raises itself These have `status` 0 and nothing was received from Caitlyn: | Code | When | | --- | --- | | `CONNECTION_ERROR` | No response (above). | | `STREAM_LOST` | A [stream](/streaming/) dropped and could not be resumed after three tries. | | `NO_FINAL_EVENT` | `finalMessage()` on a stream that ended without its final event, such as a resumed stream whose answer had already finished. | | `UPLOAD_PUT_FAILED` | An upload helper could not send the file's bytes to the upload address. | Two more are separate classes: `NotSupportedByServer`, raised before sending when the server is too old for the call ([Getting started](/getting-started/#versions-and-older-servers)), and the sign-in error, `CaitlynAuthError`, when a token could not be got ([Authentication](/authentication/#when-a-token-is-refused)). ## Error codes | Code | Status | Error class | | --- | --- | --- | | [`VALIDATION_FAILED`](/errors/VALIDATION_FAILED/) | 400 | `ValidationError` | | [`UNKNOWN_FIELD`](/errors/UNKNOWN_FIELD/) | 400 | `ValidationError` | | [`UNKNOWN_FILTER`](/errors/UNKNOWN_FILTER/) | 400 | `ValidationError` | | [`UNKNOWN_SORT`](/errors/UNKNOWN_SORT/) | 400 | `ValidationError` | | [`INVALID_CURSOR`](/errors/INVALID_CURSOR/) | 400 | `ValidationError` | | [`UNAUTHENTICATED`](/errors/UNAUTHENTICATED/) | 401 | `UnauthenticatedError` | | [`PERMISSION_DENIED`](/errors/PERMISSION_DENIED/) | 403 | `PermissionDeniedError` | | [`NOT_AVAILABLE_TO_CREDENTIALS`](/errors/NOT_AVAILABLE_TO_CREDENTIALS/) | 403 | `PermissionDeniedError` | | [`PRIMARY_WORKSPACE_REQUIRED`](/errors/PRIMARY_WORKSPACE_REQUIRED/) | 403 | `PermissionDeniedError` | | [`NOT_FOUND`](/errors/NOT_FOUND/) | 404 | `NotFoundError` | | [`CONFLICT`](/errors/CONFLICT/) | 409 | `ConflictError` | | [`ALREADY_EXISTS`](/errors/ALREADY_EXISTS/) | 409 | `ConflictError` | | [`IDEMPOTENCY_KEY_IN_USE`](/errors/IDEMPOTENCY_KEY_IN_USE/) | 409 | `ConflictError` | | [`OPERATION_NOT_CANCELLABLE`](/errors/OPERATION_NOT_CANCELLABLE/) | 409 | `ConflictError` | | [`PRECONDITION_FAILED`](/errors/PRECONDITION_FAILED/) | 412 | `PreconditionFailedError` | | [`PAYLOAD_TOO_LARGE`](/errors/PAYLOAD_TOO_LARGE/) | 413 | `PayloadTooLargeError` | | [`IDEMPOTENCY_KEY_REUSED`](/errors/IDEMPOTENCY_KEY_REUSED/) | 422 | `UnprocessableContentError` | | [`PRECONDITION_REQUIRED`](/errors/PRECONDITION_REQUIRED/) | 428 | `PreconditionRequiredError` | | [`RATE_LIMITED`](/errors/RATE_LIMITED/) | 429 | `TooManyRequestsError` | | [`INTERNAL`](/errors/INTERNAL/) | 500 | `InternalError` | | [`SERVICE_UNAVAILABLE`](/errors/SERVICE_UNAVAILABLE/) | 503 | `ServiceUnavailableError` | # Retries {#_top} The SDKs retry a failed call for you, but only when that is safe and only when trying again might help. Every SDK follows the same rules. ## What is retried - **Only operations that are safe to repeat:** reads, operations that are idempotent, and creates that carry an idempotency key. Each operation's reference page says whether the SDKs retry it. Anything else, such as sending a message, is sent once, whatever goes wrong. - **Only failures that may pass:** 429 [`RATE_LIMITED`](/errors/RATE_LIMITED/), 503 [`SERVICE_UNAVAILABLE`](/errors/SERVICE_UNAVAILABLE/), and calls that got no response at all (a refused or reset connection, a DNS failure, a timeout before the response headers). Nothing else is retried: not a 500, and not a call whose response had started to arrive. - **At most three attempts** in all, the first included, and only while the client's [retry budget](#the-retry-budget) lasts. Then the last error is raised. ## How long it waits - When the response has `Retry-After`, the SDK waits exactly that long, up to the longest wait you allow (60 seconds by default). A `Retry-After` longer than that fails the call at once with the typed error, so you never wait longer than you said. - Otherwise it waits a random time between 0 and `0.5 s × 2^retry`, at most 8 seconds ("full jitter"), so that many clients failing at once do not all come back at once. - Cancelling the call (an abort signal, a cancelled context or task) stops the wait. ## Idempotency keys A create that carries an `Idempotency-Key` can be retried without making two of anything: the server answers a repeat of the same key with the first request's result. The SDKs make a new key for every call and send the same key on each of that call's retries. To make your own retry of a whole call safe (after a crash, say), pass your own key as a call option and reuse it. Reusing a key with a different body is refused with [`IDEMPOTENCY_KEY_REUSED`](/errors/IDEMPOTENCY_KEY_REUSED/), and a repeat that arrives while the first is still running gets [`IDEMPOTENCY_KEY_IN_USE`](/errors/IDEMPOTENCY_KEY_IN_USE/). ## Changing the limits ```ts tab="TypeScript" const patient = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! }, maxAttempts: 5, // the first attempt included; default 3 maxRetryDelayMs: 120_000, // the longest Retry-After to wait for; default 60 seconds }); ``` ```python tab="Python" patient = Caitlyn( workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"]), max_attempts=5, # the first attempt included; default 3 max_retry_delay=120, # seconds: the longest Retry-After to wait for; default 60 ) ``` ```go tab="Go" patient, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN")), caitlyn.WithMaxAttempts(5), // the first attempt included; default 3 caitlyn.WithMaxRetryDelay(2*time.Minute), // the longest Retry-After to wait for; default 60 s ) ``` ```rust tab="Rust" let patient = Client::builder("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?)) .max_attempts(5) // the first attempt included; default 3 .max_retry_delay(Duration::from_secs(120)) // the longest Retry-After to wait for; default 60 s .build()?; ``` ## The retry budget Each client also has a budget for retries, shared by all its calls, so that when Caitlyn is failing or overloaded your client does not multiply every call by its retries: - The budget starts full, at 500 tokens. A retry costs 5 tokens, or 10 when the attempt it repeats got no response at all. The cost is taken when the retry is decided, before the wait. - When the budget holds less than a retry costs, the call fails at once with the error it got, without waiting. - A call that succeeds puts tokens back, up to the full budget: the cost of its last retry if it retried, or 1 if it did not. A call that fails puts nothing back. - The first attempt of a call is never blocked: the budget only decides retries. The one extra try after a 401 (below) costs nothing. With the default three attempts, a client spends its whole budget on 50 calls in a row that each fail twice, and a healthy client earns tokens back with every success, so an occasional failure never empties it. These are the numbers of the AWS SDKs' "standard" retry mode. A client that makes many calls at once, such as a batch job, may want a bigger budget; a budget of 0 turns retries off. ```ts tab="TypeScript" const batch = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! }, retryBudget: 2_000, // retry tokens shared by every call on this client; default 500, and 0 turns retries off }); ``` ```python tab="Python" batch = Caitlyn( workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"]), retry_budget=2000, # retry tokens shared by every call on this client; default 500, and 0 turns retries off ) ``` ```go tab="Go" batch, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN")), caitlyn.WithRetryBudget(2000), // retry tokens shared by every call on this client; default 500, and 0 turns retries off ) ``` ```rust tab="Rust" let batch = Client::builder("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?)) .retry_budget(2000) // retry tokens shared by every call on this client; default 500, and 0 turns retries off .build()?; ``` The SDKs do not slow themselves down to a rate of their own choosing. Caitlyn says when to slow down, with 429 and `Retry-After`, and the SDKs follow it. A 401 is handled separately: in the modes that renew tokens, the SDK gets a new token and sends the call once more, for every operation, without counting it as a retry ([Authentication](/authentication/#when-a-token-is-refused)). # Streaming {#_top} An agent's answer arrives as it is written, as a stream of typed events. [SendMessage](/reference/send-message/) returns one; each SDK gives you something to iterate, and the finished message at the end. > **Not yet available.** No Caitlyn release serves the conversation operations yet. This page describes how the > SDKs handle them. ```ts tab="TypeScript" const stream = caitlyn.conversations.send(conversationId, { message: 'How much annual leave do part-time staff get?' }); for await (const event of stream) { switch (event.type) { case 'text.delta': process.stdout.write(event.delta); break; case 'citation.added': console.log(`\n[${event.citation.number}] ${event.citation.documentName}`); break; } } const answer = await stream.finalMessage(); // the complete message, with its citations console.log(answer.id, stream.requestId); ``` ```python tab="Python" with caitlyn.conversations.send(conversation_id, message="How much annual leave do part-time staff get?") as stream: for event in stream: if isinstance(event, TextDeltaEvent): print(event.delta, end="", flush=True) elif isinstance(event, CitationAddedEvent): print(f"\n[{event.citation.number}] {event.citation.document_name}") answer = stream.final_message() # the complete message, with its citations print(answer.id, stream.request_id) ``` ```go tab="Go" stream, err := client.Conversations.Send(ctx, conversationID, &caitlyn.SendMessageInput{ Message: "How much annual leave do part-time staff get?", }) if err != nil { return err } defer stream.Close() for event, err := range stream.Events() { if err != nil { return err } switch e := event.(type) { case *caitlyn.TextDelta: fmt.Print(e.Delta) case *caitlyn.CitationAdded: fmt.Printf("\n[%d] %s\n", e.Citation.Number, *e.Citation.DocumentName) } } answer, err := stream.FinalMessage() // the complete message, with its citations if err != nil { return err } fmt.Println(answer.ID, stream.RequestID()) ``` ```rust tab="Rust" let input = SendMessageInput { message: "How much annual leave do part-time staff get?".into(), ..Default::default() }; let mut stream = client.conversations().send(CONVERSATION_ID, input).await?; while let Some(event) = stream.next().await { match event? { ConversationEvent::TextDelta(delta) => print!("{}", delta.delta), ConversationEvent::CitationAdded(added) => { println!("\n[{}] {:?}", added.citation.number, added.citation.document_name) } _ => {} } } let answer = stream.final_message().await?; // the complete message, with its citations println!("{} {:?}", answer.id, stream.request_id()); ``` ## The events Each event has a `type` (Go `EventType()`, Rust `event_type()`) and its own data. The answer starts with `message.started`, its text comes as `text.delta` fragments, and citations, progress steps and tool calls arrive as they happen. It ends with exactly one of: - `message.completed`, carrying the whole message, which `finalMessage()` (Python and Rust `final_message()`, Go `FinalMessage()`) returns. Call it after the loop, or instead of one: it reads the rest of the stream itself. - `message.failed`, a problem details object like any [error](/errors/), which the SDK raises as the typed error when the loop reaches it. [ConversationEvent](/reference/types/conversation-event/) lists every event. A newer server may send events this SDK does not know; they are skipped, so an older SDK keeps working. ## Dropped connections A proxy or load balancer can cut a long-lived connection. When the connection fails, or ends before the final event, the SDK re-attaches with [ResumeConversationStream](/reference/resume-conversation-stream/), sending the ID of the last event it saw (`Last-Event-ID`). The server replays what came after, and the SDK drops any event it has already seen, so no event is lost or repeated. After three reconnections that fail, the stream raises `STREAM_LOST`. You can re-attach yourself, from another process for example, by calling `conversations.resume` with the conversation and the last event ID you have. If the answer has already finished, that stream ends at once with no events. ## Stopping early ```ts tab="TypeScript" // Give up after 30 seconds: the connection closes and the loop throws the signal's reason const stream = caitlyn.conversations.send(conversationId, { message: 'Summarise the leave policy.' }, { signal: AbortSignal.timeout(30_000) }); for await (const event of stream) if (event.type === 'text.delta') process.stdout.write(event.delta); ``` ```go tab="Go" // Give up after 30 seconds: the connection closes and the loop returns the context's error ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() stream, err := client.Conversations.Send(ctx, conversationID, &caitlyn.SendMessageInput{Message: "Summarise the leave policy."}) if err != nil { return err } defer stream.Close() for event, err := range stream.Events() { if err != nil { return err } if delta, ok := event.(*caitlyn.TextDelta); ok { fmt.Print(delta.Delta) } } ``` In Python, leave the `with` block (or call `close()`); in Rust, drop the stream. Cancelling is never reported as a connection error or `STREAM_LOST`: you get your language's own cancellation error. Sending a message is never retried by the SDK, because sending it twice would ask the question twice. If the send fails before the stream starts, the error is yours to handle. # Pagination {#_top} A list operation returns its results a page at a time. The SDKs hide the pages: iterate the result and each page is fetched when the loop reaches it, so a `break` stops fetching. > **Not yet available.** No Caitlyn release serves [ListDocuments](/reference/list-documents/) yet. This page > describes how the SDKs handle lists. ```ts tab="TypeScript" // Every document in the folder: the next page is fetched as the loop reaches it for await (const document of caitlyn.documents.list({ knowledgeBaseId: 'KB4T7YQ2WL', folder: 'hr/', limit: 100 })) { console.log(document.name, document.status); } ``` ```python tab="Python" # Every document in the folder: the next page is fetched as the loop reaches it for document in caitlyn.documents.list(knowledge_base_id="KB4T7YQ2WL", folder="hr/", limit=100): print(document.name, document.status) # Or page by page for page in caitlyn.documents.list(knowledge_base_id="KB4T7YQ2WL", limit=100).pages(): print(len(page.items), page.next_cursor) ``` ```go tab="Go" // Every document in the folder: the next page is fetched as the loop reaches it input := &caitlyn.ListDocumentsInput{KnowledgeBaseID: "KB4T7YQ2WL", Folder: caitlyn.Ptr("hr/"), Limit: caitlyn.Ptr[int32](100)} for document, err := range client.Documents.List(ctx, input) { if err != nil { return err } fmt.Println(document.Name, document.Status) } // Or page by page for page, err := range client.Documents.ListPages(ctx, input) { if err != nil { return err } fmt.Println(len(page.Items), page.NextCursor) } ``` ```rust tab="Rust" // Every document in the folder: the next page is fetched as the loop reaches it let input = ListDocumentsInput { knowledge_base_id: "KB4T7YQ2WL".into(), folder: Some("hr/".into()), limit: Some(100), ..Default::default() }; let mut documents = client.documents().list(input.clone()); while let Some(document) = documents.next().await { let document = document?; println!("{} {:?}", document.name, document.status); } // Or page by page let mut pages = client.documents().list_pages(input); while let Some(page) = pages.next().await { let page = page?; println!("{} {:?}", page.items.len(), page.next_cursor); } ``` ## On the wire Each page is a request with `limit` (how many items to return, up to the operation's maximum) and, after the first page, `cursor`. The response has `items` and `nextCursor`; the last page has no `nextCursor`. - **The order is stable.** Items come in an order that does not change between pages, so nothing is skipped or repeated while you page. - **A cursor belongs to its list.** Use it only with the same operation, filters and sort; anything else is refused with [`INVALID_CURSOR`](/errors/INVALID_CURSOR/). - **Filters and sorting** are the operation's own, listed on its reference page. A filter or sort field it does not declare is refused ([`UNKNOWN_FILTER`](/errors/UNKNOWN_FILTER/), [`UNKNOWN_SORT`](/errors/UNKNOWN_SORT/)) rather than ignored. # Telemetry {#_top} Every SDK records each call as [OpenTelemetry](https://opentelemetry.io/) spans, with the same names and attributes in every language, so a trace looks the same whichever SDK made it. TypeScript, Python and Go use the OpenTelemetry API, which records nothing until you configure a tracer provider; Rust uses `tracing` spans, which `tracing-opentelemetry` turns into the same OpenTelemetry spans. ```ts tab="TypeScript" const traced = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! }, telemetry: { tracerProvider: trace.getTracerProvider() }, // default: the global provider; enabled: false turns spans off }); ``` ```python tab="Python" traced = Caitlyn( workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"]), tracer_provider=trace.get_tracer_provider(), # default: the global provider ) ``` ```go tab="Go" traced, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN")), caitlyn.WithTracerProvider(otel.GetTracerProvider()), // default: the global provider caitlyn.WithPropagator(propagation.TraceContext{}), // default: the global propagator ) ``` In Rust, install a `tracing` subscriber (with `tracing-opentelemetry` for OpenTelemetry), and give the client builder a `propagator` to send trace context. ## The spans | Span | One per | Attributes | | --- | --- | --- | | `caitlyn.call` | call | `rpc.system` (`caitlyn`), `rpc.method` (the operation, such as `GetAgentVersion`), `caitlyn.request_id` (the last attempt's) | | `caitlyn.attempt` | HTTP attempt, a child of the call | `http.request.method`, `server.address`, `url.full`, `http.request.resend_count` (0 for the first), and once a response arrives `http.response.status_code`, `caitlyn.request_id` and `caitlyn.server.api_version` | | `caitlyn.upload` | file upload by an upload helper | `http.request.method`, `server.address`, `url.full`, `http.response.status_code` | All are client spans; the call and upload spans are children of whatever span is current when you make the call. - **`url.full` never has the query string**, a fragment or user information: an upload address's query is its signature. - **A failure** sets the span's status to error with the error's message, `error.type` to the error's class (`NotFoundError`, `CaitlynError`, `CaitlynConnectionError`, `CaitlynAuthError`, `NotSupportedByServer`, the same in every SDK), `caitlyn.error.code` to its code, and records an `exception` event. An attempt fails when its response is 400 or above, or when no response arrived. ## Trace context When a propagator is configured, each attempt sends `traceparent`, `tracestate` and `baggage` from its span, so Caitlyn's own traces can be linked to yours. With none configured, nothing is sent. Caitlyn links a trace you send to its own rather than making its spans children of yours. # Sign in from a browser {#_top} Anyone can read the code a browser runs, and any script that runs on your page can read what that code holds. So a browser app never holds a client secret or a long-lived token. There are two ways for a web app to call Caitlyn as the person using it: 1. **Sign in on your server** (recommended for production). Your server signs the person in, keeps their Caitlyn tokens and makes the calls. The browser has only your app's own session cookie. This is the "backend for frontend" pattern in the IETF guidance on OAuth for browser-based apps. 2. **A browser-only app.** The page signs in itself with the authorisation code flow and PKCE, holds its tokens in memory and calls Caitlyn directly. Both use the SDK's `oauth` mode. Personal access tokens (`token` mode) are for scripts and local testing, never for a page other people use. > **Not live yet.** The v3 API, browser API clients and cross-origin calls to v3 are being built in > [caitlyn!2062](https://git.customd.com/custom-d/caitlyn/platform/caitlyn/-/merge_requests/2062), and the SDK's > browser checks (see [What the SDK refuses in a browser](#what-the-sdk-refuses-in-a-browser)) come in the > TypeScript SDK's next release. This page describes how they will work; until the server change is released, > pattern 2 cannot reach a Caitlyn workspace. ## Which pattern to use | | Sign in on your server | A browser-only app | | --- | --- | --- | | Use it when | You have a server, or serverless functions, in front of your page | You have no server: a static site, a prototype, an internal tool | | Where the tokens are | Encrypted in your server's store | In the page's memory | | What a malicious script on your page can do | Call your server's routes while the page is open | Read the tokens and use them elsewhere until they expire | | After a reload | Still signed in | Signs in again | | API client | A web API client, with a client secret | A browser API client, with no secret, and your page's origin registered | | Cross-origin calls to Caitlyn | None: the browser calls your own server | Yes, from your registered origin | Choose pattern 1 unless you have no server. Pattern 2 is a reasonable choice for a tool used by a few people who already have Caitlyn accounts, and a poor one for a public product. ## Pattern 1: sign in on your server 1. The browser asks your server to sign in (`/caitlyn/sign-in`). Your server builds the Caitlyn sign-in URL, keeps the state and PKCE verifier in your app's session, and redirects the browser to Caitlyn. 2. The person signs in to Caitlyn, and Caitlyn redirects the browser back to your server's callback with a code. Your server exchanges the code, with its client secret, for an access token and a refresh token, and saves them, encrypted, against the person. 3. From then on, the browser calls your server's routes with your session cookie, and your server calls Caitlyn with the person's access token, refreshing it when it is due. **Register** a web API client (see [Registering the API client](#registering-the-api-client)) with your server's callback URL as its redirect URI. It has a client secret and no allowed origins, because the browser never calls Caitlyn. Keep the secret in your server's secret store. A client per person, with a token store that encrypts their tokens on your server: ```ts // Your app's own session, from an HttpOnly, Secure, SameSite=Lax cookie. It never holds Caitlyn tokens. interface AppSession { userId: string; caitlynSignIn?: { state: string; codeVerifier: string }; } declare function getSession(request: Request): Promise; declare function saveSession(session: AppSession): Promise; // A table on your server, and encryption under a key only your server can use (a KMS key, for example). declare const tokenTable: { get(key: string): Promise; put(key: string, value: Uint8Array): Promise; delete(key: string): Promise; }; declare function encrypt(plaintext: string): Promise; declare function decrypt(ciphertext: Uint8Array): Promise; /** One person's Caitlyn tokens, encrypted at rest. The SDK saves each new refresh token here. */ function tokenStoreFor(userId: string): TokenStore { const key = `caitlyn-tokens:${userId}`; return { async load() { const sealed = await tokenTable.get(key); return sealed ? (JSON.parse(await decrypt(sealed)) as OAuthTokens) : undefined; }, async save(tokens) { await tokenTable.put(key, await encrypt(JSON.stringify(tokens))); }, async clear() { await tokenTable.delete(key); }, }; } // One client per person in each server process, so a refresh is one request however many calls need it. const clients = new Map(); function caitlynFor(userId: string): Caitlyn { let client = clients.get(userId); if (!client) { client = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'oauth', clientId: process.env.CAITLYN_CLIENT_ID!, clientSecret: process.env.CAITLYN_CLIENT_SECRET!, // only ever on your server redirectUri: 'https://app.example.com/caitlyn/callback', scopes: ['api:write'], tokenStore: tokenStoreFor(userId), }, }); clients.set(userId, client); } return client; } ``` The routes. They take and return web-standard `Request` and `Response` objects, as Next.js route handlers, Hono, Remix and Bun do: ```ts // GET /caitlyn/sign-in: send the person to Caitlyn export async function signIn(request: Request): Promise { const session = await getSession(request); const { url, state, codeVerifier } = await caitlynFor(session.userId).auth.authorizationUrl(); session.caitlynSignIn = { state, codeVerifier }; await saveSession(session); return Response.redirect(url, 302); } // GET /caitlyn/callback: Caitlyn sends the person back here with a code export async function callback(request: Request): Promise { const session = await getSession(request); const pending = session.caitlynSignIn; session.caitlynSignIn = undefined; // the state and verifier are good for one attempt only await saveSession(session); const params = new URL(request.url).searchParams; const code = params.get('code'); if (!pending || !code) return new Response('Sign-in was cancelled or failed.', { status: 400 }); await caitlynFor(session.userId).auth.handleCallback({ code, state: params.get('state') ?? undefined, expectedState: pending.state, // a different state fails with invalid_state before any request codeVerifier: pending.codeVerifier, }); return Response.redirect(new URL('/', request.url), 303); } // POST /api/ask: the browser calls your server, and your server calls Caitlyn export async function ask(request: Request): Promise { const session = await getSession(request); const { conversationId, message } = (await request.json()) as { conversationId: string; message: string }; try { const answer = await caitlynFor(session.userId).conversations.send(conversationId, { message }).finalMessage(); return Response.json({ text: answer.text }); } catch (err) { // Not signed in to Caitlyn yet, or the refresh token was refused: ask the browser to sign in again if (err instanceof CaitlynAuthError && (err.code === 'sign_in_required' || err.code === 'invalid_grant')) { return Response.json({ signIn: '/caitlyn/sign-in' }, { status: 401 }); } throw err; } } // POST /caitlyn/sign-out: revoke the refresh token and delete the stored tokens export async function signOut(request: Request): Promise { const session = await getSession(request); await caitlynFor(session.userId).auth.signOut(); clients.delete(session.userId); return new Response(null, { status: 204 }); } ``` - **Your session cookie** carries only your app's session ID: `HttpOnly`, `Secure` and `SameSite=Lax`. Caitlyn tokens never go into it, or into anything else the browser receives. - **Protect your routes against cross-site requests.** Your `/api/` routes act with the person's Caitlyn access, so accept them only from your own pages: `SameSite` cookies, and a check of the `Origin` header on every request that changes something. - **One client per person in each server process**, as `caitlynFor` does, so that one refresh serves every call waiting for it. Refresh tokens are single use: each refresh returns a new one, which the SDK saves to the store, and the old one stops working. The SDK loads the store before it refreshes, so a process that finds tokens another process has just refreshed uses those. - **Signing out** revokes the refresh token at Caitlyn and deletes the stored tokens. ## Pattern 2: a browser-only app 1. The page builds the Caitlyn sign-in URL, keeps the one-time state and PKCE verifier in `sessionStorage` (the page unloads when the browser leaves for Caitlyn), and sends the browser to Caitlyn. 2. Caitlyn redirects the browser back to your callback page with a code. The page exchanges it for tokens, with the PKCE verifier and no secret, and the SDK keeps them in memory. 3. The page calls Caitlyn directly. Caitlyn answers because the page's origin is registered on the API client. **Register** a browser API client (see [Registering the API client](#registering-the-api-client)) with: - your page's origin in `allowedOrigins`: scheme, host and port exactly, such as `https://tools.example.com` or `http://localhost:5173`; - your callback page's URL in `redirectUris`, exactly as the page sends it, on one of those origins. Caitlyn returns a `clientId`, which the page uses. A browser API client is always public: it has no secret. The client. With no token store, the SDK keeps tokens in memory: ```ts // No client secret: a public client. Tokens stay in this page's memory (the default when there is no token // store), so a reload signs in again. const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'oauth', clientId: 'your-public-client-id', // an identifier, not a secret redirectUri: `${location.origin}/callback`, // registered on the API client exactly as written here scopes: ['api:write'], }, }); ``` Signing in, and the callback page: ```ts const PENDING = 'caitlyn.sign-in'; /** Sends the person to Caitlyn to sign in. */ export async function signIn(): Promise { const { url, state, codeVerifier } = await caitlyn.auth.authorizationUrl(); // The page is about to unload, so keep the one-time state and PKCE verifier for the callback. They are not // tokens; sessionStorage keeps them for this tab only, and the callback deletes them. sessionStorage.setItem(PENDING, JSON.stringify({ state, codeVerifier })); location.assign(url); } /** On the /callback page: exchanges the code for tokens, which the client keeps in memory. */ export async function finishSignIn(): Promise { const params = new URLSearchParams(location.search); const pending = JSON.parse(sessionStorage.getItem(PENDING) ?? 'null') as { state: string; codeVerifier: string } | null; sessionStorage.removeItem(PENDING); history.replaceState(null, '', '/'); // take the code out of the address bar and the history const code = params.get('code'); if (!pending || !code) throw new Error(params.get('error_description') ?? 'Sign-in was cancelled or failed.'); await caitlyn.auth.handleCallback({ code, state: params.get('state') ?? undefined, expectedState: pending.state, // a different state fails with invalid_state before any request codeVerifier: pending.codeVerifier, }); } ``` A call. The answer streams into the page: ```ts /** Asks a question in a conversation and streams the answer into the page. */ export async function ask(conversationId: string, message: string, output: HTMLElement): Promise { try { for await (const event of caitlyn.conversations.send(conversationId, { message })) { if (event.type === 'text.delta') output.append(event.delta); } } catch (err) { // Not signed in (after a reload, for example), or the refresh token was refused: sign in again if (err instanceof CaitlynAuthError && (err.code === 'sign_in_required' || err.code === 'invalid_grant')) return signIn(); throw err; } } /** Revokes the refresh token and forgets the tokens. */ export async function signOut(): Promise { await caitlyn.auth.signOut(); } ``` - **Tokens live in memory.** An access token lasts an hour, and the SDK refreshes it a minute before it expires. Refresh tokens are single use: each refresh returns a new one and the old one stops working. A reload, or a new tab, starts with no tokens, so the first call fails with `sign_in_required` and the page signs in again. If that round trip is not acceptable, use pattern 1 rather than storing tokens. - **Only the state and verifier go in `sessionStorage`**, for the few seconds of the redirect. They are no use without the code Caitlyn sends to your registered redirect URI, and the callback deletes them. - **Take the code out of the address bar**, as `finishSignIn` does with `history.replaceState`, so it is not left in the history. - **Keep scripts you do not control off the page.** In this pattern a script injected into your page can read the tokens. A strict Content Security Policy and few third-party scripts matter more here than anywhere else. ## Registering the API client | Setting | Sign in on your server | A browser-only app | | --- | --- | --- | | Register | A web API client: confidential, with a client secret; PKCE (S256) is required too | A browser API client: public, with no secret; PKCE (S256) is required | | Redirect URIs | Your server's callback, for example `https://app.example.com/caitlyn/callback` | Your callback page, for example `https://tools.example.com/callback` | | Allowed origins | None | Your page's origin, for example `https://tools.example.com` | | Scopes | What your server's routes need, for example `api:read` | What the page needs, for example `api:write` | Both kinds are registered by someone signed in to the workspace with permission to create API clients, with a `POST` from the workspace's own pages; there is no form in the admin screens yet. They can grant only scopes whose permissions they hold themselves (otherwise 403). List clients with `GET /api/v2/api-clients?surface=api`, and revoke one with `DELETE /api/v2/api-clients/{clientId}`. **A web API client** (sign in on your server): `POST /api/v2/api-clients/web`. ```json { "clientName": "Policy portal", "scopes": ["api:read"], "redirectUris": ["https://app.example.com/caitlyn/callback"], "expiresAt": "2027-11-12T00:00:00Z" } ``` | Field | Rules | | --- | --- | | `clientName` | Up to 120 characters | | `scopes` | The `api:` scopes your server needs | | `redirectUris` | 1 to 10, matched exactly. `https`, or `http` only on `localhost`, `127.0.0.1` or `[::1]`; no wildcards, fragments or user information | | `expiresAt` | Required: when the client secret stops working. Capped, as machine client secrets are | The response has the `clientId` and the `clientSecret`, shown once (Caitlyn keeps only a hash), and when the secret expires. The secret is needed on every code exchange and refresh, and refused once it has expired. Before then, issue a new one with `POST /api/v2/api-clients/{clientId}/rotate` and a new `expiresAt`; the old secret stops working at once, so update your server's secret store in the same step. Such a client can only sign people in and act for them: it cannot use client credentials, and every cross-origin request for it is refused. **A browser API client** (a browser-only app): `POST /api/v2/api-clients/browser`. ```json { "clientName": "Policy lookup tool", "scopes": ["api:write"], "allowedOrigins": ["https://tools.example.com", "http://localhost:5173"], "redirectUris": ["https://tools.example.com/callback", "http://localhost:5173/callback"] } ``` | Field | Rules | | --- | --- | | `clientName` | Up to 120 characters | | `scopes` | The `api:` scopes the page needs | | `allowedOrigins` | 1 to 10, each exactly `scheme://host[:port]`, with no path or wildcard. `https`, or `http` only on `localhost`, `127.0.0.1` or `[::1]`. A trailing slash and capitals are normalised | | `redirectUris` | 1 to 10, matched exactly, each on one of `allowedOrigins`. The same `https` rule; no wildcards, fragments or user information | The response has the `clientId`, and no secret. Such a client can only sign people in (authorisation code with PKCE, then refresh tokens), and the sign-in must ask for the workspace's API, `resource=https:///api`, which the SDK does. To change its origins or redirect URIs, register a new client and revoke the old one with `DELETE /api/v2/api-clients/{clientId}`. List every environment's origins (`localhost`, staging, production) on the client that serves it, or register one client per environment. ## How Caitlyn treats cross-origin calls - **Only registered origins, and only v3.** Caitlyn answers a cross-origin request to `/api/v3/` or to the token endpoint only from an origin registered on a browser API client; a cross-origin call to any other part of the API is refused. The preflight (`OPTIONS`) passes only for such an origin, a v3 call only when the origin is on the client its token was issued to, and a token request only when it is on the client named by `client_id`, checked before the code or refresh token is used. - **Everything else is refused, invisibly.** A refused origin gets a 403 with no cross-origin headers, so the browser blocks it and the page cannot read even the error: you see a CORS error in the browser console. A 401 to a registered origin does carry them, so the SDK can read it and refresh. - **Never cookies.** Caitlyn never allows credentials or `*` on a cross-origin response, so a page on another origin can never act with a person's Caitlyn session. A browser-only app sends its access token in the `Authorization` header. - **Headers the page can read:** `Caitlyn-Request-Id`, `Caitlyn-Api-Version`, `Caitlyn-Idempotency-Replayed`, `ETag`, `Retry-After` and `Location`. - **One workspace.** Every token's audience is the workspace's API, `https:///api`, which the SDK sends as `resource` when it signs in; another workspace refuses it. - **Later.** Sender-bound tokens (DPoP), which would make a stolen browser token useless on another machine, are planned for browser clients and not available yet. ## What the SDK refuses in a browser The TypeScript SDK checks where it is running, and refuses to hold a secret in a browser. These checks come in its next release (`PROTOCOL.md` section 12.6 specifies them). | Sign-in | In a browser | | --- | --- | | `oauth` without `clientSecret` (pattern 2) | Allowed | | `oauth` with `clientSecret` | Refused | | `clientCredentials` | Refused | | `awsIam` | Refused | | `workloadIdentity` | Refused | | `token` (a personal access token) | Allowed with a warning, for local testing | | `session` | Allowed: the Caitlyn app's own pages | - **A refusal is immediate.** Creating the client (`new Caitlyn(...)`, `createCaitlynClient(...)` or `createAuth(...)`) throws `CaitlynAuthError` with code `not_allowed_in_browser`, before any request, so the mistake shows up the first time the page loads rather than at the first call. For a secret, the message reads: "clientCredentials cannot be used in a browser: a client secret in a browser is visible to everyone who loads the page. Use oauth without a clientSecret (authorisation code with PKCE), or sign in on your server." For `awsIam` and `workloadIdentity`, it says the mode signs in a server workload with that workload's own identity, and to use `oauth` in a browser. - **The `token` warning** is one `console.warn` per client, when it is created: "Caitlyn: a long-lived token in a browser is exposed to anyone who can run script on the page. token mode in a browser is for local testing; use oauth instead. Set auth.allowBrowserToken: true to silence this warning." To silence it while you test locally, set it on the mode: `auth: { mode: 'token', token, allowBrowserToken: true }`. - **A browser** is anywhere with both `window` and `document`. Node, Deno, Bun and web, service and shared workers are not browsers, so the same options are fine on a server and pattern 1's code is unaffected. A test environment with a simulated DOM (jsdom, happy-dom) counts as a browser: create server-side clients in tests that run without one. ## What never to do - Put a client secret, a personal access token or any other long-lived credential in browser code, a JavaScript bundle or a page's HTML. Bundles are public, whatever the build tool's environment variables suggest. - Keep tokens in `localStorage`, `sessionStorage` or IndexedDB. Any script on your origin can read them, and they outlive the page. (The one-time state and PKCE verifier in pattern 2 are not tokens.) - Send Caitlyn tokens to the browser from a pattern 1 server, in a response, a cookie or the page. - Share one API client between different apps, or list origins you do not control on it. - Turn on `allowBrowserToken` in anything other than your own local testing. # ResumeConversationStream {#_top} Re-attaches to the answer that is being written, from the event after `Last-Event-ID`. Used by the SDKs after a dropped connection; callers rarely need it directly. When no answer is being written, the stream ends at once. > **Not yet available.** The SDKs have this operation, but no Caitlyn release serves it yet: a call > gets 404. This page describes how it will work. [GetAgentVersion](/reference/get-agent-version/) is the one v3 > operation served today. | | | | --- | --- | | HTTP | `GET /api/v3/conversations/{conversationId}/stream` | | TypeScript | `caitlyn.conversations.resume(conversationId, { … })` | | Python | `caitlyn.conversations.resume(conversation_id, …)` | | Go | `client.Conversations.Resume(ctx, conversationID, &caitlyn.ResumeConversationStreamInput{…})` | | Rust | `client.conversations().resume(conversation_id, ResumeConversationStreamInput { … })` | | Stability | Generally available | | Since | Caitlyn 5.115.0 | | Retried by the SDKs | Yes, on 429, 503 and connection failures ([Retries](/retries/)) | | Audited | As a write: Streams a conversation's answer, which is part of its transcript. The call is refused with 503 when its audit event cannot be recorded. | ## Request Member names are shown as they are in TypeScript and on the wire. Python and Rust use snake case (`knowledge_base_id`) and Go uses exported names (`KnowledgeBaseID`). `conversationId` is the method's first argument rather than an input field. | Member | Type | Sent in | Description | | --- | --- | --- | --- | | `conversationId` (required) | string | path | The conversation whose answer to re-attach to. 1 to 128 characters. | | `lastEventId` | string | header `Last-Event-ID` | The `id` of the last event received. Absent means from the start of the answer. 1 to 128 characters. | ## Response A `text/event-stream` of [ConversationEvent](/reference/types/conversation-event/) events. The stream ends after `message.completed`, which the SDKs' `finalMessage()` (Python `final_message()`, Go `FinalMessage()`, Rust `final_message()`) returns; a failure ends it with `message.failed`, which the SDKs raise as the typed error. See [Streaming](/streaming/). | Event | Data | Description | | --- | --- | --- | | `message.started` | [MessageStarted](/reference/types/message-started/) | The answer has started. | | `phase.changed` | [PhaseChanged](/reference/types/phase-changed/) | The agent moved to another phase of its workflow. | | `progress.updated` | [ProgressUpdated](/reference/types/progress-updated/) | A progress step started, changed or finished. | | `text.delta` | [TextDelta](/reference/types/text-delta/) | A fragment of answer text. The one recorded exception to the noun.past_tense naming rule. | | `citation.added` | [CitationAdded](/reference/types/citation-added/) | A citation was added to the answer. | | `tool.started` | [ToolStarted](/reference/types/tool-started/) | A tool call started. | | `tool.finished` | [ToolFinished](/reference/types/tool-finished/) | A tool call finished. | | `input.requested` | [InputRequested](/reference/types/input-requested/) | The agent asked the person for input through a widget. | | `message.completed` | [MessageCompleted](/reference/types/message-completed/) | The answer is complete. Ends the stream. | | `message.failed` | [MessageFailed](/reference/types/message-failed/) | The answer failed. Ends the stream; its data is a problem details object. | Response headers: | Member | Type | Sent in | Description | | --- | --- | --- | --- | | `requestId` | string | header `Caitlyn-Request-Id` | The request's identifier. The same value is in every error body; quote it to support. 1 to 128 characters. | | `apiVersion` | string | header `Caitlyn-Api-Version` | The server's model version, for example `5.120.0`. The SDKs keep the latest value they have seen and refuse to call operations or send members newer than it. 5 to 32 characters. Pattern `^[0-9]+\.[0-9]+\.[0-9]+$`. | ## Permissions - **Permission:** `chat-session-messages.read`. - **Checked on:** the conversation named by `conversationId`. - **Who can call it:** people (signed in, or a personal access token), API clients (machine sign-in), guests (a guest token). The checks run in this order: who is calling, the workspace and the permission (each refused with 403), then the record (404, which is also what a record the caller may not see returns). [Errors](/errors/) explains why. ## Errors The SDKs raise the error class for the response's HTTP status, whether or not the operation lists it ([Errors](/errors/)). The statuses this operation returns: | Status | Error | Codes | When | | --- | --- | --- | --- | | 400 | `ValidationError` | [`VALIDATION_FAILED`](/errors/VALIDATION_FAILED/), [`UNKNOWN_FIELD`](/errors/UNKNOWN_FIELD/), [`UNKNOWN_FILTER`](/errors/UNKNOWN_FILTER/), [`UNKNOWN_SORT`](/errors/UNKNOWN_SORT/), [`INVALID_CURSOR`](/errors/INVALID_CURSOR/) | The request is malformed: a member is missing, the wrong type or out of range; it has a member, filter or sort field the operation does not define; or its cursor is not valid for this list. | | 401 | `UnauthenticatedError` | [`UNAUTHENTICATED`](/errors/UNAUTHENTICATED/) | No credential was sent, or it is expired or not valid. | | 403 | `PermissionDeniedError` | [`PERMISSION_DENIED`](/errors/PERMISSION_DENIED/), [`NOT_AVAILABLE_TO_CREDENTIALS`](/errors/NOT_AVAILABLE_TO_CREDENTIALS/), [`PRIMARY_WORKSPACE_REQUIRED`](/errors/PRIMARY_WORKSPACE_REQUIRED/) | The caller may not call this operation: it lacks the permission, its credential type may not call it, or the operation is served only on the organisation's primary workspace. Decided before any record is read. A record the caller may not see is 404 instead. | | 404 | `NotFoundError` | [`NOT_FOUND`](/errors/NOT_FOUND/) | The record does not exist, or the caller may not see it. The two are deliberately the same, so identifiers cannot be probed. | | 429 | `TooManyRequestsError` | [`RATE_LIMITED`](/errors/RATE_LIMITED/) | Too many requests; wait for `Retry-After` and try again. Retried by the SDKs. | | 500 | `InternalError` | [`INTERNAL`](/errors/INTERNAL/) | An unexpected server failure. Not retried by the SDKs. | | 503 | `ServiceUnavailableError` | [`SERVICE_UNAVAILABLE`](/errors/SERVICE_UNAVAILABLE/) | A dependency is briefly unavailable, or the request's audit event could not be recorded; wait for `Retry-After` and try again. Retried by the SDKs. | ## Examples ### Resume after a dropped connection The answer arrives as a stream of events; the example shows only the request. ```ts tab="TypeScript" import { Caitlyn } from '@caitlyn-ai/sdk'; const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! } }); const stream = caitlyn.conversations.resume('0193a6f2-7c4e-7b1a-9d2e-3f5a8c1b0e47', { lastEventId: '0193a6f2-9b10-7c3d-8e44-1f2a3b4c5d6e', }); for await (const event of stream) { if (event.type === 'text.delta') process.stdout.write(event.delta); } console.log(await stream.finalMessage()); ``` ```python tab="Python" import os from caitlyn import Caitlyn, TextDeltaEvent from caitlyn.auth import Token caitlyn = Caitlyn(workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"])) with caitlyn.conversations.resume( "0193a6f2-7c4e-7b1a-9d2e-3f5a8c1b0e47", last_event_id="0193a6f2-9b10-7c3d-8e44-1f2a3b4c5d6e", ) as stream: for event in stream: if isinstance(event, TextDeltaEvent): print(event.delta, end="", flush=True) print(stream.final_message()) ``` ```go tab="Go" package main import ( "context" "fmt" "log" "os" caitlyn "github.com/caitlyn-ai/caitlyn-go" ) func main() { ctx := context.Background() client, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) if err != nil { log.Fatal(err) } stream, err := client.Conversations.Resume(ctx, "0193a6f2-7c4e-7b1a-9d2e-3f5a8c1b0e47", &caitlyn.ResumeConversationStreamInput{ LastEventID: caitlyn.Ptr("0193a6f2-9b10-7c3d-8e44-1f2a3b4c5d6e"), }) if err != nil { log.Fatal(err) } defer stream.Close() for event, err := range stream.Events() { if err != nil { log.Fatal(err) } if delta, ok := event.(*caitlyn.TextDelta); ok { fmt.Print(delta.Delta) } } } ``` ```rust tab="Rust" use caitlyn_sdk::{Auth, Client, ConversationEvent, ResumeConversationStreamInput}; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; let mut stream = client .conversations() .resume("0193a6f2-7c4e-7b1a-9d2e-3f5a8c1b0e47", ResumeConversationStreamInput { last_event_id: Some("0193a6f2-9b10-7c3d-8e44-1f2a3b4c5d6e".into()), }) .await?; while let Some(event) = stream.next().await { if let ConversationEvent::TextDelta(delta) = event? { print!("{}", delta.delta); } } Ok(()) } ``` The HTTP exchange: ```http GET /api/v3/conversations/0193a6f2-7c4e-7b1a-9d2e-3f5a8c1b0e47/stream HTTP/1.1 Host: your-company.getcaitlyn.ai Authorization: Bearer Accept: text/event-stream Last-Event-ID: 0193a6f2-9b10-7c3d-8e44-1f2a3b4c5d6e ``` ```http HTTP/1.1 200 Content-Type: text/event-stream ``` ### A conversation that does not exist, or that the caller may not see ```ts tab="TypeScript" import { Caitlyn, NotFoundError } from '@caitlyn-ai/sdk'; const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! } }); try { const stream = caitlyn.conversations.resume('0193a6f2-0000-7000-8000-000000000000', {}); for await (const event of stream) { if (event.type === 'text.delta') process.stdout.write(event.delta); } console.log(await stream.finalMessage()); } catch (err) { if (!(err instanceof NotFoundError)) throw err; console.error(err.status, err.code, err.detail, err.requestId); } ``` ```python tab="Python" import os from caitlyn import Caitlyn, NotFoundError, TextDeltaEvent from caitlyn.auth import Token caitlyn = Caitlyn(workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"])) try: with caitlyn.conversations.resume("0193a6f2-0000-7000-8000-000000000000") as stream: for event in stream: if isinstance(event, TextDeltaEvent): print(event.delta, end="", flush=True) print(stream.final_message()) except NotFoundError as err: print(err.status, err.code, err.detail, err.request_id) ``` ```go tab="Go" package main import ( "context" "errors" "fmt" "log" "os" caitlyn "github.com/caitlyn-ai/caitlyn-go" ) func main() { ctx := context.Background() client, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) if err != nil { log.Fatal(err) } stream, err := client.Conversations.Resume(ctx, "0193a6f2-0000-7000-8000-000000000000", &caitlyn.ResumeConversationStreamInput{}) if err != nil { var apiErr *caitlyn.NotFoundError if errors.As(err, &apiErr) { fmt.Println(apiErr.Status, apiErr.Code, apiErr.Detail, apiErr.RequestID) return } log.Fatal(err) } defer stream.Close() for event, err := range stream.Events() { if err != nil { log.Fatal(err) } if delta, ok := event.(*caitlyn.TextDelta); ok { fmt.Print(delta.Delta) } } } ``` ```rust tab="Rust" use caitlyn_sdk::{Auth, Client, ConversationEvent, ResumeConversationStreamInput}; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; let outcome: Result<(), caitlyn_sdk::Error> = async { let mut stream = client .conversations() .resume("0193a6f2-0000-7000-8000-000000000000", ResumeConversationStreamInput { ..Default::default() }) .await?; while let Some(event) = stream.next().await { if let ConversationEvent::TextDelta(delta) = event? { print!("{}", delta.delta); } } Ok(()) } .await; match outcome { Err(caitlyn_sdk::Error::NotFound(err)) => eprintln!("{} {} {:?} {:?}", err.status, err.code, err.detail, err.request_id), other => other?, } Ok(()) } ``` The HTTP exchange: ```http GET /api/v3/conversations/0193a6f2-0000-7000-8000-000000000000/stream HTTP/1.1 Host: your-company.getcaitlyn.ai Authorization: Bearer Accept: text/event-stream ``` ```http HTTP/1.1 404 Content-Type: application/problem+json { "type": "https://build.caitlyn.ai/errors/NOT_FOUND", "title": "Not found", "status": 404, "code": "NOT_FOUND", "requestId": "01JC9Q2W7R4M0H2T6N1S8V3B62" } ``` # SendMessage {#_top} Sends a message and streams the answer as server-sent events. The stream ends with exactly one `message.completed` or `message.failed`. If the connection drops, the SDK re-attaches with `ResumeConversationStream` and `Last-Event-ID`, so no event is lost or repeated. Not retried automatically: a send that fails before the stream starts is reported to the caller. > **Not yet available.** The SDKs have this operation, but no Caitlyn release serves it yet: a call > gets 404. This page describes how it will work. [GetAgentVersion](/reference/get-agent-version/) is the one v3 > operation served today. | | | | --- | --- | | HTTP | `POST /api/v3/conversations/{conversationId}/send` | | TypeScript | `caitlyn.conversations.send(conversationId, { … })` | | Python | `caitlyn.conversations.send(conversation_id, …)` | | Go | `client.Conversations.Send(ctx, conversationID, &caitlyn.SendMessageInput{…})` | | Rust | `client.conversations().send(conversation_id, SendMessageInput { … })` | | Stability | Generally available | | Since | Caitlyn 5.115.0 | | Retried by the SDKs | No: it is not safe to repeat ([Retries](/retries/)) | ## Request Member names are shown as they are in TypeScript and on the wire. Python and Rust use snake case (`knowledge_base_id`) and Go uses exported names (`KnowledgeBaseID`). `conversationId` is the method's first argument rather than an input field. | Member | Type | Sent in | Description | | --- | --- | --- | --- | | `conversationId` (required) | string | path | The conversation to send to. 1 to 128 characters. | | `message` (required) | string | body | The message, at most 32,000 characters. 1 to 32000 characters. | | `context` | list of [ContextItem](/reference/types/context-item/) | body | Structured context for this message only, at most 20 items and 16 KB in total. Not stored on the conversation. At most 20 items. | ## Response A `text/event-stream` of [ConversationEvent](/reference/types/conversation-event/) events. The stream ends after `message.completed`, which the SDKs' `finalMessage()` (Python `final_message()`, Go `FinalMessage()`, Rust `final_message()`) returns; a failure ends it with `message.failed`, which the SDKs raise as the typed error. A dropped connection is resumed with [ResumeConversationStream](/reference/resume-conversation-stream/) and `Last-Event-ID`, so no event is lost or repeated. See [Streaming](/streaming/). | Event | Data | Description | | --- | --- | --- | | `message.started` | [MessageStarted](/reference/types/message-started/) | The answer has started. | | `phase.changed` | [PhaseChanged](/reference/types/phase-changed/) | The agent moved to another phase of its workflow. | | `progress.updated` | [ProgressUpdated](/reference/types/progress-updated/) | A progress step started, changed or finished. | | `text.delta` | [TextDelta](/reference/types/text-delta/) | A fragment of answer text. The one recorded exception to the noun.past_tense naming rule. | | `citation.added` | [CitationAdded](/reference/types/citation-added/) | A citation was added to the answer. | | `tool.started` | [ToolStarted](/reference/types/tool-started/) | A tool call started. | | `tool.finished` | [ToolFinished](/reference/types/tool-finished/) | A tool call finished. | | `input.requested` | [InputRequested](/reference/types/input-requested/) | The agent asked the person for input through a widget. | | `message.completed` | [MessageCompleted](/reference/types/message-completed/) | The answer is complete. Ends the stream. | | `message.failed` | [MessageFailed](/reference/types/message-failed/) | The answer failed. Ends the stream; its data is a problem details object. | Response headers: | Member | Type | Sent in | Description | | --- | --- | --- | --- | | `requestId` | string | header `Caitlyn-Request-Id` | The request's identifier. The same value is in every error body; quote it to support. 1 to 128 characters. | | `apiVersion` | string | header `Caitlyn-Api-Version` | The server's model version, for example `5.120.0`. The SDKs keep the latest value they have seen and refuse to call operations or send members newer than it. 5 to 32 characters. Pattern `^[0-9]+\.[0-9]+\.[0-9]+$`. | ## Permissions - **Permission:** `chat-session-messages.create`. - **Checked on:** the conversation named by `conversationId`. - **Who can call it:** people (signed in, or a personal access token), API clients (machine sign-in), guests (a guest token). The checks run in this order: who is calling, the workspace and the permission (each refused with 403), then the record (404, which is also what a record the caller may not see returns). [Errors](/errors/) explains why. ## Errors The SDKs raise the error class for the response's HTTP status, whether or not the operation lists it ([Errors](/errors/)). The statuses this operation returns: | Status | Error | Codes | When | | --- | --- | --- | --- | | 400 | `ValidationError` | [`VALIDATION_FAILED`](/errors/VALIDATION_FAILED/), [`UNKNOWN_FIELD`](/errors/UNKNOWN_FIELD/), [`UNKNOWN_FILTER`](/errors/UNKNOWN_FILTER/), [`UNKNOWN_SORT`](/errors/UNKNOWN_SORT/), [`INVALID_CURSOR`](/errors/INVALID_CURSOR/) | The request is malformed: a member is missing, the wrong type or out of range; it has a member, filter or sort field the operation does not define; or its cursor is not valid for this list. | | 401 | `UnauthenticatedError` | [`UNAUTHENTICATED`](/errors/UNAUTHENTICATED/) | No credential was sent, or it is expired or not valid. | | 403 | `PermissionDeniedError` | [`PERMISSION_DENIED`](/errors/PERMISSION_DENIED/), [`NOT_AVAILABLE_TO_CREDENTIALS`](/errors/NOT_AVAILABLE_TO_CREDENTIALS/), [`PRIMARY_WORKSPACE_REQUIRED`](/errors/PRIMARY_WORKSPACE_REQUIRED/) | The caller may not call this operation: it lacks the permission, its credential type may not call it, or the operation is served only on the organisation's primary workspace. Decided before any record is read. A record the caller may not see is 404 instead. | | 404 | `NotFoundError` | [`NOT_FOUND`](/errors/NOT_FOUND/) | The record does not exist, or the caller may not see it. The two are deliberately the same, so identifiers cannot be probed. | | 409 | `ConflictError` | [`CONFLICT`](/errors/CONFLICT/), [`ALREADY_EXISTS`](/errors/ALREADY_EXISTS/), [`IDEMPOTENCY_KEY_IN_USE`](/errors/IDEMPOTENCY_KEY_IN_USE/), [`OPERATION_NOT_CANCELLABLE`](/errors/OPERATION_NOT_CANCELLABLE/) | The resource's state does not allow the request, a unique name is taken, or a request with the same idempotency key is still in progress. | | 413 | `PayloadTooLargeError` | [`PAYLOAD_TOO_LARGE`](/errors/PAYLOAD_TOO_LARGE/) | The body, a file or the send `context` is over its documented limit. | | 429 | `TooManyRequestsError` | [`RATE_LIMITED`](/errors/RATE_LIMITED/) | Too many requests; wait for `Retry-After` and try again. Retried by the SDKs. | | 500 | `InternalError` | [`INTERNAL`](/errors/INTERNAL/) | An unexpected server failure. Not retried by the SDKs. | | 503 | `ServiceUnavailableError` | [`SERVICE_UNAVAILABLE`](/errors/SERVICE_UNAVAILABLE/) | A dependency is briefly unavailable, or the request's audit event could not be recorded; wait for `Retry-After` and try again. Retried by the SDKs. | ## Examples ### Ask a question The answer arrives as a stream of events; the example shows only the request. ```ts tab="TypeScript" import { Caitlyn } from '@caitlyn-ai/sdk'; const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! } }); const stream = caitlyn.conversations.send('0193a6f2-7c4e-7b1a-9d2e-3f5a8c1b0e47', { message: 'How much annual leave do part-time staff get?', }); for await (const event of stream) { if (event.type === 'text.delta') process.stdout.write(event.delta); } console.log(await stream.finalMessage()); ``` ```python tab="Python" import os from caitlyn import Caitlyn, TextDeltaEvent from caitlyn.auth import Token caitlyn = Caitlyn(workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"])) with caitlyn.conversations.send( "0193a6f2-7c4e-7b1a-9d2e-3f5a8c1b0e47", message="How much annual leave do part-time staff get?", ) as stream: for event in stream: if isinstance(event, TextDeltaEvent): print(event.delta, end="", flush=True) print(stream.final_message()) ``` ```go tab="Go" package main import ( "context" "fmt" "log" "os" caitlyn "github.com/caitlyn-ai/caitlyn-go" ) func main() { ctx := context.Background() client, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) if err != nil { log.Fatal(err) } stream, err := client.Conversations.Send(ctx, "0193a6f2-7c4e-7b1a-9d2e-3f5a8c1b0e47", &caitlyn.SendMessageInput{ Message: "How much annual leave do part-time staff get?", }) if err != nil { log.Fatal(err) } defer stream.Close() for event, err := range stream.Events() { if err != nil { log.Fatal(err) } if delta, ok := event.(*caitlyn.TextDelta); ok { fmt.Print(delta.Delta) } } } ``` ```rust tab="Rust" use caitlyn_sdk::{Auth, Client, ConversationEvent, SendMessageInput}; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; let mut stream = client .conversations() .send("0193a6f2-7c4e-7b1a-9d2e-3f5a8c1b0e47", SendMessageInput { message: "How much annual leave do part-time staff get?".into(), ..Default::default() }) .await?; while let Some(event) = stream.next().await { if let ConversationEvent::TextDelta(delta) = event? { print!("{}", delta.delta); } } Ok(()) } ``` The HTTP exchange: ```http POST /api/v3/conversations/0193a6f2-7c4e-7b1a-9d2e-3f5a8c1b0e47/send HTTP/1.1 Host: your-company.getcaitlyn.ai Authorization: Bearer Accept: text/event-stream Content-Type: application/json { "message": "How much annual leave do part-time staff get?" } ``` ```http HTTP/1.1 200 Content-Type: text/event-stream ``` ### A conversation that does not exist, or that the caller may not see ```ts tab="TypeScript" import { Caitlyn, NotFoundError } from '@caitlyn-ai/sdk'; const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! } }); try { const stream = caitlyn.conversations.send('0193a6f2-0000-7000-8000-000000000000', { message: 'Hello' }); for await (const event of stream) { if (event.type === 'text.delta') process.stdout.write(event.delta); } console.log(await stream.finalMessage()); } catch (err) { if (!(err instanceof NotFoundError)) throw err; console.error(err.status, err.code, err.detail, err.requestId); } ``` ```python tab="Python" import os from caitlyn import Caitlyn, NotFoundError, TextDeltaEvent from caitlyn.auth import Token caitlyn = Caitlyn(workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"])) try: with caitlyn.conversations.send( "0193a6f2-0000-7000-8000-000000000000", message="Hello", ) as stream: for event in stream: if isinstance(event, TextDeltaEvent): print(event.delta, end="", flush=True) print(stream.final_message()) except NotFoundError as err: print(err.status, err.code, err.detail, err.request_id) ``` ```go tab="Go" package main import ( "context" "errors" "fmt" "log" "os" caitlyn "github.com/caitlyn-ai/caitlyn-go" ) func main() { ctx := context.Background() client, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) if err != nil { log.Fatal(err) } stream, err := client.Conversations.Send(ctx, "0193a6f2-0000-7000-8000-000000000000", &caitlyn.SendMessageInput{ Message: "Hello", }) if err != nil { var apiErr *caitlyn.NotFoundError if errors.As(err, &apiErr) { fmt.Println(apiErr.Status, apiErr.Code, apiErr.Detail, apiErr.RequestID) return } log.Fatal(err) } defer stream.Close() for event, err := range stream.Events() { if err != nil { log.Fatal(err) } if delta, ok := event.(*caitlyn.TextDelta); ok { fmt.Print(delta.Delta) } } } ``` ```rust tab="Rust" use caitlyn_sdk::{Auth, Client, ConversationEvent, SendMessageInput}; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; let outcome: Result<(), caitlyn_sdk::Error> = async { let mut stream = client .conversations() .send("0193a6f2-0000-7000-8000-000000000000", SendMessageInput { message: "Hello".into(), ..Default::default() }) .await?; while let Some(event) = stream.next().await { if let ConversationEvent::TextDelta(delta) = event? { print!("{}", delta.delta); } } Ok(()) } .await; match outcome { Err(caitlyn_sdk::Error::NotFound(err)) => eprintln!("{} {} {:?} {:?}", err.status, err.code, err.detail, err.request_id), other => other?, } Ok(()) } ``` The HTTP exchange: ```http POST /api/v3/conversations/0193a6f2-0000-7000-8000-000000000000/send HTTP/1.1 Host: your-company.getcaitlyn.ai Authorization: Bearer Accept: text/event-stream Content-Type: application/json { "message": "Hello" } ``` ```http HTTP/1.1 404 Content-Type: application/problem+json { "type": "https://build.caitlyn.ai/errors/NOT_FOUND", "title": "Not found", "status": 404, "code": "NOT_FOUND", "requestId": "01JC9Q2W7R4M0H2T6N1S8V3B60" } ``` ### An answer is already being written ```ts tab="TypeScript" import { Caitlyn, ConflictError } from '@caitlyn-ai/sdk'; const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! } }); try { const stream = caitlyn.conversations.send('0193a6f2-7c4e-7b1a-9d2e-3f5a8c1b0e47', { message: 'And for casual staff?', }); for await (const event of stream) { if (event.type === 'text.delta') process.stdout.write(event.delta); } console.log(await stream.finalMessage()); } catch (err) { if (!(err instanceof ConflictError)) throw err; console.error(err.status, err.code, err.detail, err.requestId); } ``` ```python tab="Python" import os from caitlyn import Caitlyn, ConflictError, TextDeltaEvent from caitlyn.auth import Token caitlyn = Caitlyn(workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"])) try: with caitlyn.conversations.send( "0193a6f2-7c4e-7b1a-9d2e-3f5a8c1b0e47", message="And for casual staff?", ) as stream: for event in stream: if isinstance(event, TextDeltaEvent): print(event.delta, end="", flush=True) print(stream.final_message()) except ConflictError as err: print(err.status, err.code, err.detail, err.request_id) ``` ```go tab="Go" package main import ( "context" "errors" "fmt" "log" "os" caitlyn "github.com/caitlyn-ai/caitlyn-go" ) func main() { ctx := context.Background() client, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) if err != nil { log.Fatal(err) } stream, err := client.Conversations.Send(ctx, "0193a6f2-7c4e-7b1a-9d2e-3f5a8c1b0e47", &caitlyn.SendMessageInput{ Message: "And for casual staff?", }) if err != nil { var apiErr *caitlyn.ConflictError if errors.As(err, &apiErr) { fmt.Println(apiErr.Status, apiErr.Code, apiErr.Detail, apiErr.RequestID) return } log.Fatal(err) } defer stream.Close() for event, err := range stream.Events() { if err != nil { log.Fatal(err) } if delta, ok := event.(*caitlyn.TextDelta); ok { fmt.Print(delta.Delta) } } } ``` ```rust tab="Rust" use caitlyn_sdk::{Auth, Client, ConversationEvent, SendMessageInput}; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; let outcome: Result<(), caitlyn_sdk::Error> = async { let mut stream = client .conversations() .send("0193a6f2-7c4e-7b1a-9d2e-3f5a8c1b0e47", SendMessageInput { message: "And for casual staff?".into(), ..Default::default() }) .await?; while let Some(event) = stream.next().await { if let ConversationEvent::TextDelta(delta) = event? { print!("{}", delta.delta); } } Ok(()) } .await; match outcome { Err(caitlyn_sdk::Error::Conflict(err)) => eprintln!("{} {} {:?} {:?}", err.status, err.code, err.detail, err.request_id), other => other?, } Ok(()) } ``` The HTTP exchange: ```http POST /api/v3/conversations/0193a6f2-7c4e-7b1a-9d2e-3f5a8c1b0e47/send HTTP/1.1 Host: your-company.getcaitlyn.ai Authorization: Bearer Accept: text/event-stream Content-Type: application/json { "message": "And for casual staff?" } ``` ```http HTTP/1.1 409 Content-Type: application/problem+json { "type": "https://build.caitlyn.ai/errors/CONFLICT", "title": "Conflict", "status": 409, "code": "CONFLICT", "detail": "The conversation is still answering the previous message. Wait for it to finish, or resume its stream.", "requestId": "01JC9Q2W7R4M0H2T6N1S8V3B61" } ``` # CreateDocument {#_top} Creates a document from an upload; ingestion continues after the response. Returns 202 with the document in its initial state and the Operation that tracks ingestion, and `Location` names the Operation. Needs the files permission, read access to the knowledge base and access to the folder; it does not need update access to the knowledge base, which is for the knowledge base's own configuration. > **Not yet available.** The SDKs have this operation, but no Caitlyn release serves it yet: a call > gets 404. This page describes how it will work. [GetAgentVersion](/reference/get-agent-version/) is the one v3 > operation served today. | | | | --- | --- | | HTTP | `POST /api/v3/knowledge-bases/{knowledgeBaseId}/documents` | | TypeScript | `caitlyn.documents.create({ … })` | | Python | `caitlyn.documents.create(…)` | | Go | `client.Documents.Create(ctx, &caitlyn.CreateDocumentInput{…})` | | Rust | `client.documents().create(CreateDocumentInput { … })` | | Stability | Generally available | | Since | Caitlyn 5.115.0 | | Retried by the SDKs | Yes, on 429, 503 and connection failures ([Retries](/retries/)) | The SDKs also have `documents.upload(file, …)` (Python `documents.upload(file=…, …)`, Go `Documents.Upload`, Rust `documents().upload`), which uploads the file first and then calls this operation with `uploadId` set to the upload's ID. ## Request Member names are shown as they are in TypeScript and on the wire. Python and Rust use snake case (`knowledge_base_id`) and Go uses exported names (`KnowledgeBaseID`). `idempotencyKey` is optional: the SDKs generate one per call and reuse it on every retry. Pass your own as a call option (TypeScript `{ idempotencyKey }`, Python `idempotency_key=`, Go `caitlyn.WithIdempotencyKey`, Rust `.idempotency_key(…)`) to make a retry of your own safe. | Member | Type | Sent in | Description | | --- | --- | --- | --- | | `knowledgeBaseId` (required) | string | path | The knowledge base to add the document to. 1 to 128 characters. | | `idempotencyKey` | string | header `Idempotency-Key` | Makes the request safe to retry. The SDKs generate one per call when the caller does not supply one. 1 to 255 characters. | | `uploadId` (required) | string | body | The upload holding the document's bytes. Each upload can be used once. 1 to 128 characters. | | `folder` (required) | string | body | The folder to put the document in. 1 to 1024 characters. Pattern `^(/|([^/]+/)+)$`. | | `name` | string | body | The document's name. Absent means the uploaded file's name. 1 to 1024 characters. | ## Response Status 202. | Member | Type | Sent in | Description | | --- | --- | --- | --- | | `requestId` | string | header `Caitlyn-Request-Id` | The request's identifier. The same value is in every error body; quote it to support. 1 to 128 characters. | | `apiVersion` | string | header `Caitlyn-Api-Version` | The server's model version, for example `5.120.0`. The SDKs keep the latest value they have seen and refuse to call operations or send members newer than it. 5 to 32 characters. Pattern `^[0-9]+\.[0-9]+\.[0-9]+$`. | | `location` | string | header `Location` | The Operation that tracks ingestion, as `/api/v3/operations/{operationId}`. 1 to 2048 characters. | | `document` (required) | [Document](/reference/types/document/) | body | The document in its initial state. | | `operation` (required) | [Operation](/reference/types/operation/) | body | The Operation that tracks ingestion. | Every result also keeps the JSON it was read from, unknown fields included, as `raw`, and the response metadata (HTTP status, request ID, ETag). ## Permissions - **Permission:** `files.create`. - **Checked on:** the knowledge bas named by `knowledgeBaseId`, which needs the `read` grant. The folder in `folder` must be one the caller can reach. - **Who can call it:** people (signed in, or a personal access token), API clients (machine sign-in). The checks run in this order: who is calling, the workspace and the permission (each refused with 403), then the record (404, which is also what a record the caller may not see returns). [Errors](/errors/) explains why. ## Errors The SDKs raise the error class for the response's HTTP status, whether or not the operation lists it ([Errors](/errors/)). The statuses this operation returns: | Status | Error | Codes | When | | --- | --- | --- | --- | | 400 | `ValidationError` | [`VALIDATION_FAILED`](/errors/VALIDATION_FAILED/), [`UNKNOWN_FIELD`](/errors/UNKNOWN_FIELD/), [`UNKNOWN_FILTER`](/errors/UNKNOWN_FILTER/), [`UNKNOWN_SORT`](/errors/UNKNOWN_SORT/), [`INVALID_CURSOR`](/errors/INVALID_CURSOR/) | The request is malformed: a member is missing, the wrong type or out of range; it has a member, filter or sort field the operation does not define; or its cursor is not valid for this list. | | 401 | `UnauthenticatedError` | [`UNAUTHENTICATED`](/errors/UNAUTHENTICATED/) | No credential was sent, or it is expired or not valid. | | 403 | `PermissionDeniedError` | [`PERMISSION_DENIED`](/errors/PERMISSION_DENIED/), [`NOT_AVAILABLE_TO_CREDENTIALS`](/errors/NOT_AVAILABLE_TO_CREDENTIALS/), [`PRIMARY_WORKSPACE_REQUIRED`](/errors/PRIMARY_WORKSPACE_REQUIRED/) | The caller may not call this operation: it lacks the permission, its credential type may not call it, or the operation is served only on the organisation's primary workspace. Decided before any record is read. A record the caller may not see is 404 instead. | | 404 | `NotFoundError` | [`NOT_FOUND`](/errors/NOT_FOUND/) | The record does not exist, or the caller may not see it. The two are deliberately the same, so identifiers cannot be probed. | | 409 | `ConflictError` | [`CONFLICT`](/errors/CONFLICT/), [`ALREADY_EXISTS`](/errors/ALREADY_EXISTS/), [`IDEMPOTENCY_KEY_IN_USE`](/errors/IDEMPOTENCY_KEY_IN_USE/), [`OPERATION_NOT_CANCELLABLE`](/errors/OPERATION_NOT_CANCELLABLE/) | The resource's state does not allow the request, a unique name is taken, or a request with the same idempotency key is still in progress. | | 422 | `UnprocessableContentError` | [`IDEMPOTENCY_KEY_REUSED`](/errors/IDEMPOTENCY_KEY_REUSED/) | The idempotency key was already used with a different request body. | | 429 | `TooManyRequestsError` | [`RATE_LIMITED`](/errors/RATE_LIMITED/) | Too many requests; wait for `Retry-After` and try again. Retried by the SDKs. | | 500 | `InternalError` | [`INTERNAL`](/errors/INTERNAL/) | An unexpected server failure. Not retried by the SDKs. | | 503 | `ServiceUnavailableError` | [`SERVICE_UNAVAILABLE`](/errors/SERVICE_UNAVAILABLE/) | A dependency is briefly unavailable, or the request's audit event could not be recorded; wait for `Retry-After` and try again. Retried by the SDKs. | ## Examples ### Add an uploaded file to the HR folder ```ts tab="TypeScript" import { Caitlyn } from '@caitlyn-ai/sdk'; const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! } }); const result = await caitlyn.documents.create({ knowledgeBaseId: 'KB4T7YQ2WL', uploadId: '0193a6f2-1111-7a2b-8c3d-4e5f60718293', folder: 'hr/', name: 'Leave policy', }, { idempotencyKey: '3f0c9a1e-5d2b-4f7a-9c1e-2b8d4a6f0e13' }); console.log(result); ``` ```python tab="Python" import os from caitlyn import Caitlyn from caitlyn.auth import Token caitlyn = Caitlyn(workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"])) result = caitlyn.documents.create( idempotency_key="3f0c9a1e-5d2b-4f7a-9c1e-2b8d4a6f0e13", knowledge_base_id="KB4T7YQ2WL", upload_id="0193a6f2-1111-7a2b-8c3d-4e5f60718293", folder="hr/", name="Leave policy", ) print(result) ``` ```go tab="Go" package main import ( "context" "encoding/json" "fmt" "log" "os" caitlyn "github.com/caitlyn-ai/caitlyn-go" ) func main() { ctx := context.Background() client, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) if err != nil { log.Fatal(err) } result, err := client.Documents.Create(ctx, &caitlyn.CreateDocumentInput{ KnowledgeBaseID: "KB4T7YQ2WL", UploadID: "0193a6f2-1111-7a2b-8c3d-4e5f60718293", Folder: "hr/", Name: caitlyn.Ptr("Leave policy"), }, caitlyn.WithIdempotencyKey("3f0c9a1e-5d2b-4f7a-9c1e-2b8d4a6f0e13")) if err != nil { log.Fatal(err) } out, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(out)) } ``` ```rust tab="Rust" use caitlyn_sdk::{Auth, Client, CreateDocumentInput}; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; let result = client .documents() .create(CreateDocumentInput { knowledge_base_id: "KB4T7YQ2WL".into(), upload_id: "0193a6f2-1111-7a2b-8c3d-4e5f60718293".into(), folder: "hr/".into(), name: Some("Leave policy".into()), }) .idempotency_key("3f0c9a1e-5d2b-4f7a-9c1e-2b8d4a6f0e13") .await?; println!("{result:#?}"); Ok(()) } ``` The HTTP exchange: ```http POST /api/v3/knowledge-bases/KB4T7YQ2WL/documents HTTP/1.1 Host: your-company.getcaitlyn.ai Authorization: Bearer Idempotency-Key: 3f0c9a1e-5d2b-4f7a-9c1e-2b8d4a6f0e13 Content-Type: application/json { "uploadId": "0193a6f2-1111-7a2b-8c3d-4e5f60718293", "folder": "hr/", "name": "Leave policy" } ``` ```http HTTP/1.1 202 Location: /api/v3/operations/0193a6f2-2222-7b3c-9d4e-5f6071829304 Content-Type: application/json { "document": { "id": "doc-3b7f9e21", "knowledgeBaseId": "KB4T7YQ2WL", "name": "Leave policy", "folder": "hr/", "status": "pending", "createdAt": "2026-11-10T01:02:03.000Z", "updatedAt": "2026-11-10T01:02:03.000Z" }, "operation": { "id": "0193a6f2-2222-7b3c-9d4e-5f6071829304", "kind": "document_ingestion", "status": "queued", "createdAt": "2026-11-10T01:02:03.000Z" } } ``` ### The upload was already used, or has expired ```ts tab="TypeScript" import { Caitlyn, ConflictError } from '@caitlyn-ai/sdk'; const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! } }); try { const result = await caitlyn.documents.create({ knowledgeBaseId: 'KB4T7YQ2WL', uploadId: '0193a6f2-1111-7a2b-8c3d-4e5f60718293', folder: 'hr/', }); console.log(result); } catch (err) { if (!(err instanceof ConflictError)) throw err; console.error(err.status, err.code, err.detail, err.requestId); } ``` ```python tab="Python" import os from caitlyn import Caitlyn, ConflictError from caitlyn.auth import Token caitlyn = Caitlyn(workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"])) try: result = caitlyn.documents.create( knowledge_base_id="KB4T7YQ2WL", upload_id="0193a6f2-1111-7a2b-8c3d-4e5f60718293", folder="hr/", ) print(result) except ConflictError as err: print(err.status, err.code, err.detail, err.request_id) ``` ```go tab="Go" package main import ( "context" "encoding/json" "errors" "fmt" "log" "os" caitlyn "github.com/caitlyn-ai/caitlyn-go" ) func main() { ctx := context.Background() client, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) if err != nil { log.Fatal(err) } result, err := client.Documents.Create(ctx, &caitlyn.CreateDocumentInput{ KnowledgeBaseID: "KB4T7YQ2WL", UploadID: "0193a6f2-1111-7a2b-8c3d-4e5f60718293", Folder: "hr/", }) if err != nil { var apiErr *caitlyn.ConflictError if errors.As(err, &apiErr) { fmt.Println(apiErr.Status, apiErr.Code, apiErr.Detail, apiErr.RequestID) return } log.Fatal(err) } out, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(out)) } ``` ```rust tab="Rust" use caitlyn_sdk::{Auth, Client, CreateDocumentInput}; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; let outcome: Result<(), caitlyn_sdk::Error> = async { let result = client .documents() .create(CreateDocumentInput { knowledge_base_id: "KB4T7YQ2WL".into(), upload_id: "0193a6f2-1111-7a2b-8c3d-4e5f60718293".into(), folder: "hr/".into(), ..Default::default() }) .await?; println!("{result:#?}"); Ok(()) } .await; match outcome { Err(caitlyn_sdk::Error::Conflict(err)) => eprintln!("{} {} {:?} {:?}", err.status, err.code, err.detail, err.request_id), other => other?, } Ok(()) } ``` The HTTP exchange: ```http POST /api/v3/knowledge-bases/KB4T7YQ2WL/documents HTTP/1.1 Host: your-company.getcaitlyn.ai Authorization: Bearer Idempotency-Key: Content-Type: application/json { "uploadId": "0193a6f2-1111-7a2b-8c3d-4e5f60718293", "folder": "hr/" } ``` ```http HTTP/1.1 409 Content-Type: application/problem+json { "type": "https://build.caitlyn.ai/errors/CONFLICT", "title": "Conflict", "status": 409, "code": "CONFLICT", "detail": "Upload 0193a6f2-1111-7a2b-8c3d-4e5f60718293 has already been used. Reserve a new upload.", "requestId": "01JC9Q2W7R4M0H2T6N1S8V3B64" } ``` # ListDocuments {#_top} Lists the documents in a knowledge base, one page at a time. Documents are returned in a stable order that does not change between pages. Filters combine with AND. Unlike v2, an unknown filter is refused with 400 `UNKNOWN_FILTER` instead of being ignored, and the list can be sorted by `name`, `updatedAt` (when the document last changed) and `byteSize` on the server; v2 sorts on the client only. > **Not yet available.** The SDKs have this operation, but no Caitlyn release serves it yet: a call > gets 404. This page describes how it will work. [GetAgentVersion](/reference/get-agent-version/) is the one v3 > operation served today. | | | | --- | --- | | HTTP | `GET /api/v3/knowledge-bases/{knowledgeBaseId}/documents` | | TypeScript | `caitlyn.documents.list({ … })` | | Python | `caitlyn.documents.list(…)` | | Go | `client.Documents.List(ctx, &caitlyn.ListDocumentsInput{…})` | | Rust | `client.documents().list(ListDocumentsInput { … })` | | Stability | Generally available | | Since | Caitlyn 5.115.0 | | Retried by the SDKs | Yes, on 429, 503 and connection failures ([Retries](/retries/)) | ## Request Member names are shown as they are in TypeScript and on the wire. Python and Rust use snake case (`knowledge_base_id`) and Go uses exported names (`KnowledgeBaseID`). | Member | Type | Sent in | Description | | --- | --- | --- | --- | | `knowledgeBaseId` (required) | string | path | The knowledge base to list. 1 to 128 characters. | | `limit` | integer | query `limit` | The most documents to return, from 1 to 100. Defaults to 20. | | `cursor` | string | query `cursor` | The `nextCursor` from the previous page. Absent for the first page. 1 to 2048 characters. | | `sort` | string | query `sort` | The order: `name`, `updatedAt` or `byteSize`, comma-separated, with a leading `-` for descending (for example `-updatedAt`). Any other field is refused with 400 `UNKNOWN_SORT`. 1 to 256 characters. | | `folder` | string | query `filter[folder][eq]` | Only documents directly in this folder. 1 to 1024 characters. Pattern `^(/|([^/]+/)+)$`. | | `status` | [DocumentStatus](/reference/types/document-status/) | query `filter[status][eq]` | Only documents with this status. | | `namePrefix` | string | query `filter[name][startsWith]` | Only documents whose name starts with this text. 1 to 1024 characters. | Filters combine with AND. A filter or operator not listed here is refused with 400 [`UNKNOWN_FILTER`](/errors/UNKNOWN_FILTER/). Sortable by `name`, `updatedAt`, `byteSize`. Any other field is refused with 400 [`UNKNOWN_SORT`](/errors/UNKNOWN_SORT/). The SDKs page through the list for you: iterate the result and each page is fetched as it is needed ([Pagination](/pagination/)). ## Response Status 200. One page of results; the SDKs return the items across every page. | Member | Type | Sent in | Description | | --- | --- | --- | --- | | `requestId` | string | header `Caitlyn-Request-Id` | The request's identifier. The same value is in every error body; quote it to support. 1 to 128 characters. | | `apiVersion` | string | header `Caitlyn-Api-Version` | The server's model version, for example `5.120.0`. The SDKs keep the latest value they have seen and refuse to call operations or send members newer than it. 5 to 32 characters. Pattern `^[0-9]+\.[0-9]+\.[0-9]+$`. | | `items` (required) | list of [Document](/reference/types/document/) | body | This page of documents. | | `nextCursor` | string | body | The cursor for the next page. Absent on the last page. 1 to 2048 characters. | Every result also keeps the JSON it was read from, unknown fields included, as `raw`, and the response metadata (HTTP status, request ID, ETag). ## Permissions - **Permission:** `files.list`. - **Checked on:** the knowledge bas named by `knowledgeBaseId`, which needs the `read` grant. - **Listed:** only what the caller's knowledge bas grants allow. - **Who can call it:** people (signed in, or a personal access token), API clients (machine sign-in). The checks run in this order: who is calling, the workspace and the permission (each refused with 403), then the record (404, which is also what a record the caller may not see returns). [Errors](/errors/) explains why. ## Errors The SDKs raise the error class for the response's HTTP status, whether or not the operation lists it ([Errors](/errors/)). The statuses this operation returns: | Status | Error | Codes | When | | --- | --- | --- | --- | | 400 | `ValidationError` | [`VALIDATION_FAILED`](/errors/VALIDATION_FAILED/), [`UNKNOWN_FIELD`](/errors/UNKNOWN_FIELD/), [`UNKNOWN_FILTER`](/errors/UNKNOWN_FILTER/), [`UNKNOWN_SORT`](/errors/UNKNOWN_SORT/), [`INVALID_CURSOR`](/errors/INVALID_CURSOR/) | The request is malformed: a member is missing, the wrong type or out of range; it has a member, filter or sort field the operation does not define; or its cursor is not valid for this list. | | 401 | `UnauthenticatedError` | [`UNAUTHENTICATED`](/errors/UNAUTHENTICATED/) | No credential was sent, or it is expired or not valid. | | 403 | `PermissionDeniedError` | [`PERMISSION_DENIED`](/errors/PERMISSION_DENIED/), [`NOT_AVAILABLE_TO_CREDENTIALS`](/errors/NOT_AVAILABLE_TO_CREDENTIALS/), [`PRIMARY_WORKSPACE_REQUIRED`](/errors/PRIMARY_WORKSPACE_REQUIRED/) | The caller may not call this operation: it lacks the permission, its credential type may not call it, or the operation is served only on the organisation's primary workspace. Decided before any record is read. A record the caller may not see is 404 instead. | | 404 | `NotFoundError` | [`NOT_FOUND`](/errors/NOT_FOUND/) | The record does not exist, or the caller may not see it. The two are deliberately the same, so identifiers cannot be probed. | | 429 | `TooManyRequestsError` | [`RATE_LIMITED`](/errors/RATE_LIMITED/) | Too many requests; wait for `Retry-After` and try again. Retried by the SDKs. | | 500 | `InternalError` | [`INTERNAL`](/errors/INTERNAL/) | An unexpected server failure. Not retried by the SDKs. | | 503 | `ServiceUnavailableError` | [`SERVICE_UNAVAILABLE`](/errors/SERVICE_UNAVAILABLE/) | A dependency is briefly unavailable, or the request's audit event could not be recorded; wait for `Retry-After` and try again. Retried by the SDKs. | ## Examples ### List the HR folder's documents that finished indexing ```ts tab="TypeScript" import { Caitlyn } from '@caitlyn-ai/sdk'; const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! } }); for await (const item of caitlyn.documents.list({ knowledgeBaseId: 'KB4T7YQ2WL', limit: 2, folder: 'hr/', status: 'indexed', })) { console.log(item); } ``` ```python tab="Python" import os from caitlyn import Caitlyn from caitlyn.auth import Token caitlyn = Caitlyn(workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"])) for item in caitlyn.documents.list( knowledge_base_id="KB4T7YQ2WL", limit=2, folder="hr/", status="indexed", ): print(item) ``` ```go tab="Go" package main import ( "context" "encoding/json" "fmt" "log" "os" caitlyn "github.com/caitlyn-ai/caitlyn-go" ) func main() { ctx := context.Background() client, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) if err != nil { log.Fatal(err) } for item, err := range client.Documents.List(ctx, &caitlyn.ListDocumentsInput{ KnowledgeBaseID: "KB4T7YQ2WL", Limit: caitlyn.Ptr[int32](2), Folder: caitlyn.Ptr("hr/"), Status: caitlyn.Ptr(caitlyn.DocumentStatusIndexed), }) { if err != nil { log.Fatal(err) } out, _ := json.Marshal(item) fmt.Println(string(out)) } } ``` ```rust tab="Rust" use caitlyn_sdk::{Auth, Client, DocumentStatus, ListDocumentsInput}; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; let mut items = client .documents() .list(ListDocumentsInput { knowledge_base_id: "KB4T7YQ2WL".into(), limit: Some(2), folder: Some("hr/".into()), status: Some(DocumentStatus::Indexed), ..Default::default() }); while let Some(item) = items.next().await { println!("{:#?}", item?); } Ok(()) } ``` The HTTP exchange: ```http GET /api/v3/knowledge-bases/KB4T7YQ2WL/documents?limit=2&filter[folder][eq]=hr%2F&filter[status][eq]=indexed HTTP/1.1 Host: your-company.getcaitlyn.ai Authorization: Bearer ``` ```http HTTP/1.1 200 Content-Type: application/json { "items": [ { "id": "doc-3b7f9e21", "knowledgeBaseId": "KB4T7YQ2WL", "name": "Leave policy", "folder": "hr/", "status": "indexed", "contentType": "application/pdf", "byteSize": 482113, "createdAt": "2026-10-01T22:10:00.000Z", "updatedAt": "2026-10-01T22:14:31.000Z" } ], "nextCursor": "c1.MEUCIQDl8z" } ``` ### A knowledge base that does not exist, or that the caller may not see ```ts tab="TypeScript" import { Caitlyn, NotFoundError } from '@caitlyn-ai/sdk'; const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! } }); try { for await (const item of caitlyn.documents.list({ knowledgeBaseId: 'KBNOPE0000' })) { console.log(item); } } catch (err) { if (!(err instanceof NotFoundError)) throw err; console.error(err.status, err.code, err.detail, err.requestId); } ``` ```python tab="Python" import os from caitlyn import Caitlyn, NotFoundError from caitlyn.auth import Token caitlyn = Caitlyn(workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"])) try: for item in caitlyn.documents.list(knowledge_base_id="KBNOPE0000"): print(item) except NotFoundError as err: print(err.status, err.code, err.detail, err.request_id) ``` ```go tab="Go" package main import ( "context" "encoding/json" "errors" "fmt" "log" "os" caitlyn "github.com/caitlyn-ai/caitlyn-go" ) func main() { ctx := context.Background() client, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) if err != nil { log.Fatal(err) } for item, err := range client.Documents.List(ctx, &caitlyn.ListDocumentsInput{KnowledgeBaseID: "KBNOPE0000"}) { if err != nil { var apiErr *caitlyn.NotFoundError if errors.As(err, &apiErr) { fmt.Println(apiErr.Status, apiErr.Code, apiErr.Detail, apiErr.RequestID) return } log.Fatal(err) } out, _ := json.Marshal(item) fmt.Println(string(out)) } } ``` ```rust tab="Rust" use caitlyn_sdk::{Auth, Client, ListDocumentsInput}; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; let outcome: Result<(), caitlyn_sdk::Error> = async { let mut items = client .documents() .list(ListDocumentsInput { knowledge_base_id: "KBNOPE0000".into(), ..Default::default() }); while let Some(item) = items.next().await { println!("{:#?}", item?); } Ok(()) } .await; match outcome { Err(caitlyn_sdk::Error::NotFound(err)) => eprintln!("{} {} {:?} {:?}", err.status, err.code, err.detail, err.request_id), other => other?, } Ok(()) } ``` The HTTP exchange: ```http GET /api/v3/knowledge-bases/KBNOPE0000/documents HTTP/1.1 Host: your-company.getcaitlyn.ai Authorization: Bearer ``` ```http HTTP/1.1 404 Content-Type: application/problem+json { "type": "https://build.caitlyn.ai/errors/NOT_FOUND", "title": "Not found", "status": 404, "code": "NOT_FOUND", "requestId": "01JC9Q2W7R4M0H2T6N1S8V3B63" } ``` # CreateUpload {#_top} Reserves a short-lived slot for a file's bytes and returns a presigned URL bound to their checksum. Send the bytes with `uploadMethod` to `uploadUrl`, with every header in `requiredHeaders`, before `expiresAt`; then pass the upload's `id` to the operation that uses it. The SDKs' `uploads.create(file)` does all of this. > **Not yet available.** The SDKs have this operation, but no Caitlyn release serves it yet: a call > gets 404. This page describes how it will work. [GetAgentVersion](/reference/get-agent-version/) is the one v3 > operation served today. | | | | --- | --- | | HTTP | `POST /api/v3/uploads` | | TypeScript | `caitlyn.uploads.reserve({ … })` | | Python | `caitlyn.uploads.reserve(…)` | | Go | `client.Uploads.Reserve(ctx, &caitlyn.CreateUploadInput{…})` | | Rust | `client.uploads().reserve(CreateUploadInput { … })` | | Stability | Generally available | | Since | Caitlyn 5.115.0 | | Retried by the SDKs | Yes, on 429, 503 and connection failures ([Retries](/retries/)) | The SDKs also have `uploads.create(file)` (Python `uploads.create(file=…)`, Go `Uploads.Create`, Rust `uploads().create`), which computes the checksum, reserves the upload and sends the bytes to `uploadUrl` with `requiredHeaders`, returning the reserved upload. ## Request Member names are shown as they are in TypeScript and on the wire. Python and Rust use snake case (`knowledge_base_id`) and Go uses exported names (`KnowledgeBaseID`). `idempotencyKey` is optional: the SDKs generate one per call and reuse it on every retry. Pass your own as a call option (TypeScript `{ idempotencyKey }`, Python `idempotency_key=`, Go `caitlyn.WithIdempotencyKey`, Rust `.idempotency_key(…)`) to make a retry of your own safe. | Member | Type | Sent in | Description | | --- | --- | --- | --- | | `idempotencyKey` | string | header `Idempotency-Key` | Makes the request safe to retry. The SDKs generate one per call when the caller does not supply one. 1 to 255 characters. | | `fileName` (required) | string | body | The file's name on the caller's machine. 1 to 1024 characters. | | `contentType` (required) | string | body | The file's media type. 3 to 255 characters. | | `byteSize` (required) | long | body | The file's size in bytes. | | `sha256` (required) | string | body | The base64-encoded SHA-256 of the file's bytes. The upload URL accepts only these bytes. 44 to 44 characters. Pattern `^[A-Za-z0-9+/]{43}=$`. | ## Response Status 201. | Member | Type | Sent in | Description | | --- | --- | --- | --- | | `requestId` | string | header `Caitlyn-Request-Id` | The request's identifier. The same value is in every error body; quote it to support. 1 to 128 characters. | | `apiVersion` | string | header `Caitlyn-Api-Version` | The server's model version, for example `5.120.0`. The SDKs keep the latest value they have seen and refuse to call operations or send members newer than it. 5 to 32 characters. Pattern `^[0-9]+\.[0-9]+\.[0-9]+$`. | | `location` | string | header `Location` | The upload, as `/api/v3/uploads/{uploadId}`. 1 to 2048 characters. | | `id` (required) | string | body | The upload's identifier, passed to the operation that uses it. 1 to 128 characters. | | `uploadUrl` (required) | string | body | Where to send the bytes. Expires at `expiresAt`. 1 to 4096 characters. | | `uploadMethod` (required) | string | body | The HTTP method to send the bytes with, usually `PUT`. 3 to 16 characters. Pattern `^[A-Z]+$`. | | `requiredHeaders` (required) | map of string to string | body | Headers that must be sent with the bytes, exactly as given. | | `expiresAt` (required) | timestamp (RFC 3339) | body | When the upload URL stops working. | | `status` (required) | [UploadStatus](/reference/types/upload-status/) | body | Where the upload is in its lifecycle. | Every result also keeps the JSON it was read from, unknown fields included, as `raw`, and the response metadata (HTTP status, request ID, ETag). ## Permissions - **Permission:** `files.create`. - **Who can call it:** people (signed in, or a personal access token), API clients (machine sign-in). The checks run in this order: who is calling, the workspace and the permission (each refused with 403), then the record (404, which is also what a record the caller may not see returns). [Errors](/errors/) explains why. ## Errors The SDKs raise the error class for the response's HTTP status, whether or not the operation lists it ([Errors](/errors/)). The statuses this operation returns: | Status | Error | Codes | When | | --- | --- | --- | --- | | 400 | `ValidationError` | [`VALIDATION_FAILED`](/errors/VALIDATION_FAILED/), [`UNKNOWN_FIELD`](/errors/UNKNOWN_FIELD/), [`UNKNOWN_FILTER`](/errors/UNKNOWN_FILTER/), [`UNKNOWN_SORT`](/errors/UNKNOWN_SORT/), [`INVALID_CURSOR`](/errors/INVALID_CURSOR/) | The request is malformed: a member is missing, the wrong type or out of range; it has a member, filter or sort field the operation does not define; or its cursor is not valid for this list. | | 401 | `UnauthenticatedError` | [`UNAUTHENTICATED`](/errors/UNAUTHENTICATED/) | No credential was sent, or it is expired or not valid. | | 403 | `PermissionDeniedError` | [`PERMISSION_DENIED`](/errors/PERMISSION_DENIED/), [`NOT_AVAILABLE_TO_CREDENTIALS`](/errors/NOT_AVAILABLE_TO_CREDENTIALS/), [`PRIMARY_WORKSPACE_REQUIRED`](/errors/PRIMARY_WORKSPACE_REQUIRED/) | The caller may not call this operation: it lacks the permission, its credential type may not call it, or the operation is served only on the organisation's primary workspace. Decided before any record is read. A record the caller may not see is 404 instead. | | 409 | `ConflictError` | [`CONFLICT`](/errors/CONFLICT/), [`ALREADY_EXISTS`](/errors/ALREADY_EXISTS/), [`IDEMPOTENCY_KEY_IN_USE`](/errors/IDEMPOTENCY_KEY_IN_USE/), [`OPERATION_NOT_CANCELLABLE`](/errors/OPERATION_NOT_CANCELLABLE/) | The resource's state does not allow the request, a unique name is taken, or a request with the same idempotency key is still in progress. | | 413 | `PayloadTooLargeError` | [`PAYLOAD_TOO_LARGE`](/errors/PAYLOAD_TOO_LARGE/) | The body, a file or the send `context` is over its documented limit. | | 422 | `UnprocessableContentError` | [`IDEMPOTENCY_KEY_REUSED`](/errors/IDEMPOTENCY_KEY_REUSED/) | The idempotency key was already used with a different request body. | | 429 | `TooManyRequestsError` | [`RATE_LIMITED`](/errors/RATE_LIMITED/) | Too many requests; wait for `Retry-After` and try again. Retried by the SDKs. | | 500 | `InternalError` | [`INTERNAL`](/errors/INTERNAL/) | An unexpected server failure. Not retried by the SDKs. | | 503 | `ServiceUnavailableError` | [`SERVICE_UNAVAILABLE`](/errors/SERVICE_UNAVAILABLE/) | A dependency is briefly unavailable, or the request's audit event could not be recorded; wait for `Retry-After` and try again. Retried by the SDKs. | ## Examples ### Reserve an upload for a PDF ```ts tab="TypeScript" import { Caitlyn } from '@caitlyn-ai/sdk'; const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! } }); const result = await caitlyn.uploads.reserve({ fileName: 'leave-policy.pdf', contentType: 'application/pdf', byteSize: 482113, sha256: 'n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=', }, { idempotencyKey: '9b1d4e2a-7c3f-4a8e-b5d6-0e1f2a3b4c5d' }); console.log(result); ``` ```python tab="Python" import os from caitlyn import Caitlyn from caitlyn.auth import Token caitlyn = Caitlyn(workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"])) result = caitlyn.uploads.reserve( idempotency_key="9b1d4e2a-7c3f-4a8e-b5d6-0e1f2a3b4c5d", file_name="leave-policy.pdf", content_type="application/pdf", byte_size=482113, sha256="n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=", ) print(result) ``` ```go tab="Go" package main import ( "context" "encoding/json" "fmt" "log" "os" caitlyn "github.com/caitlyn-ai/caitlyn-go" ) func main() { ctx := context.Background() client, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) if err != nil { log.Fatal(err) } result, err := client.Uploads.Reserve(ctx, &caitlyn.CreateUploadInput{ FileName: "leave-policy.pdf", ContentType: "application/pdf", ByteSize: 482113, SHA256: "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=", }, caitlyn.WithIdempotencyKey("9b1d4e2a-7c3f-4a8e-b5d6-0e1f2a3b4c5d")) if err != nil { log.Fatal(err) } out, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(out)) } ``` ```rust tab="Rust" use caitlyn_sdk::{Auth, Client, CreateUploadInput}; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; let result = client .uploads() .reserve(CreateUploadInput { file_name: "leave-policy.pdf".into(), content_type: "application/pdf".into(), byte_size: 482113, sha256: "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=".into(), }) .idempotency_key("9b1d4e2a-7c3f-4a8e-b5d6-0e1f2a3b4c5d") .await?; println!("{result:#?}"); Ok(()) } ``` The HTTP exchange: ```http POST /api/v3/uploads HTTP/1.1 Host: your-company.getcaitlyn.ai Authorization: Bearer Idempotency-Key: 9b1d4e2a-7c3f-4a8e-b5d6-0e1f2a3b4c5d Content-Type: application/json { "fileName": "leave-policy.pdf", "contentType": "application/pdf", "byteSize": 482113, "sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" } ``` ```http HTTP/1.1 201 Location: /api/v3/uploads/0193a6f2-1111-7a2b-8c3d-4e5f60718293 Content-Type: application/json { "id": "0193a6f2-1111-7a2b-8c3d-4e5f60718293", "uploadUrl": "https://uploads.example.com/0193a6f2-1111-7a2b-8c3d-4e5f60718293?signature=abc", "uploadMethod": "PUT", "requiredHeaders": { "Content-Type": "application/pdf" }, "expiresAt": "2026-11-10T01:17:03.000Z", "status": "awaiting_bytes" } ``` ### A file over the size limit ```ts tab="TypeScript" import { Caitlyn, PayloadTooLargeError } from '@caitlyn-ai/sdk'; const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! } }); try { const result = await caitlyn.uploads.reserve({ fileName: 'archive.zip', contentType: 'application/zip', byteSize: 9000000000, sha256: 'n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=', }); console.log(result); } catch (err) { if (!(err instanceof PayloadTooLargeError)) throw err; console.error(err.status, err.code, err.detail, err.requestId); } ``` ```python tab="Python" import os from caitlyn import Caitlyn, PayloadTooLargeError from caitlyn.auth import Token caitlyn = Caitlyn(workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"])) try: result = caitlyn.uploads.reserve( file_name="archive.zip", content_type="application/zip", byte_size=9000000000, sha256="n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=", ) print(result) except PayloadTooLargeError as err: print(err.status, err.code, err.detail, err.request_id) ``` ```go tab="Go" package main import ( "context" "encoding/json" "errors" "fmt" "log" "os" caitlyn "github.com/caitlyn-ai/caitlyn-go" ) func main() { ctx := context.Background() client, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) if err != nil { log.Fatal(err) } result, err := client.Uploads.Reserve(ctx, &caitlyn.CreateUploadInput{ FileName: "archive.zip", ContentType: "application/zip", ByteSize: 9000000000, SHA256: "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=", }) if err != nil { var apiErr *caitlyn.PayloadTooLargeError if errors.As(err, &apiErr) { fmt.Println(apiErr.Status, apiErr.Code, apiErr.Detail, apiErr.RequestID) return } log.Fatal(err) } out, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(out)) } ``` ```rust tab="Rust" use caitlyn_sdk::{Auth, Client, CreateUploadInput}; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; let outcome: Result<(), caitlyn_sdk::Error> = async { let result = client .uploads() .reserve(CreateUploadInput { file_name: "archive.zip".into(), content_type: "application/zip".into(), byte_size: 9000000000, sha256: "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=".into(), }) .await?; println!("{result:#?}"); Ok(()) } .await; match outcome { Err(caitlyn_sdk::Error::PayloadTooLarge(err)) => eprintln!("{} {} {:?} {:?}", err.status, err.code, err.detail, err.request_id), other => other?, } Ok(()) } ``` The HTTP exchange: ```http POST /api/v3/uploads HTTP/1.1 Host: your-company.getcaitlyn.ai Authorization: Bearer Idempotency-Key: Content-Type: application/json { "fileName": "archive.zip", "contentType": "application/zip", "byteSize": 9000000000, "sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" } ``` ```http HTTP/1.1 413 Content-Type: application/problem+json { "type": "https://build.caitlyn.ai/errors/PAYLOAD_TOO_LARGE", "title": "Payload too large", "status": 413, "code": "PAYLOAD_TOO_LARGE", "detail": "Files can be at most 500 MB.", "requestId": "01JC9Q2W7R4M0H2T6N1S8V3B65" } ``` # GetAgentVersion {#_top} Gets one version of an agent, with its full configuration and its ETag. `version` is `draft`, `production` or the name of a pinned or published version. Production is readable by everyone with access to the agent; the draft and pinned versions only by the people and groups on the agent's version access grant, and anyone else gets 404. | | | | --- | --- | | HTTP | `GET /api/v3/agents/{agentId}/versions/{version}` | | TypeScript | `caitlyn.versions.get({ … })` | | Python | `caitlyn.versions.get(…)` | | Go | `client.Versions.Get(ctx, &caitlyn.GetAgentVersionInput{…})` | | Rust | `client.versions().get(GetAgentVersionInput { … })` | | Stability | Generally available | | Since | Caitlyn 5.115.0 | | Retried by the SDKs | Yes, on 429, 503 and connection failures ([Retries](/retries/)) | ## Request Member names are shown as they are in TypeScript and on the wire. Python and Rust use snake case (`knowledge_base_id`) and Go uses exported names (`KnowledgeBaseID`). | Member | Type | Sent in | Description | | --- | --- | --- | --- | | `agentId` (required) | string | path | The agent's identifier. 1 to 128 characters. | | `version` (required) | string | path | `draft`, `production` or a version name. 1 to 63 characters. Pattern `^[a-z0-9][a-z0-9._-]{0,62}$`. | ## Response Status 200. | Member | Type | Sent in | Description | | --- | --- | --- | --- | | `requestId` | string | header `Caitlyn-Request-Id` | The request's identifier. The same value is in every error body; quote it to support. 1 to 128 characters. | | `apiVersion` | string | header `Caitlyn-Api-Version` | The server's model version, for example `5.120.0`. The SDKs keep the latest value they have seen and refuse to call operations or send members newer than it. 5 to 32 characters. Pattern `^[0-9]+\.[0-9]+\.[0-9]+$`. | | `etag` (required) | string | header `ETag` | The version's current ETag; send it as `If-Match` to change it. 2 to 256 characters. | | `agentId` (required) | string | body | The agent the version belongs to. 1 to 128 characters. | | `name` (required) | string | body | `draft`, `production` or the version's name. 1 to 63 characters. Pattern `^[a-z0-9][a-z0-9._-]{0,62}$`. | | `source` | [VersionSource](/reference/types/version-source/) | body | How a pinned or published version was made. Absent on the draft. | | `basedOn` | string | body | The version this one was made from, where there is one. 1 to 63 characters. Pattern `^[a-z0-9][a-z0-9._-]{0,62}$`. | | `matchesProduction` | boolean | body | True when this version's configuration is the one production currently answers with. Defaults to false. | | `createdAt` (required) | timestamp (RFC 3339) | body | When the version was made; for the draft, when it was first created. | | `note` | string | body | The note written when the version was pinned or published. 0 to 2000 characters. | | `configuration` (required) | [AgentConfiguration](/reference/types/agent-configuration/) | body | The version's configuration. | Every result also keeps the JSON it was read from, unknown fields included, as `raw`, and the response metadata (HTTP status, request ID, ETag). ## Permissions - **Permission:** `agents.read`. - **Checked on:** the agent named by `agentId`. The version in `version` is checked too: `production` needs only access to the agent; the draft and pinned versions also need the agent's version access grant. - **Who can call it:** people (signed in, or a personal access token), API clients (machine sign-in). The checks run in this order: who is calling, the workspace and the permission (each refused with 403), then the record (404, which is also what a record the caller may not see returns). [Errors](/errors/) explains why. ## Errors The SDKs raise the error class for the response's HTTP status, whether or not the operation lists it ([Errors](/errors/)). The statuses this operation returns: | Status | Error | Codes | When | | --- | --- | --- | --- | | 400 | `ValidationError` | [`VALIDATION_FAILED`](/errors/VALIDATION_FAILED/), [`UNKNOWN_FIELD`](/errors/UNKNOWN_FIELD/), [`UNKNOWN_FILTER`](/errors/UNKNOWN_FILTER/), [`UNKNOWN_SORT`](/errors/UNKNOWN_SORT/), [`INVALID_CURSOR`](/errors/INVALID_CURSOR/) | The request is malformed: a member is missing, the wrong type or out of range; it has a member, filter or sort field the operation does not define; or its cursor is not valid for this list. | | 401 | `UnauthenticatedError` | [`UNAUTHENTICATED`](/errors/UNAUTHENTICATED/) | No credential was sent, or it is expired or not valid. | | 403 | `PermissionDeniedError` | [`PERMISSION_DENIED`](/errors/PERMISSION_DENIED/), [`NOT_AVAILABLE_TO_CREDENTIALS`](/errors/NOT_AVAILABLE_TO_CREDENTIALS/), [`PRIMARY_WORKSPACE_REQUIRED`](/errors/PRIMARY_WORKSPACE_REQUIRED/) | The caller may not call this operation: it lacks the permission, its credential type may not call it, or the operation is served only on the organisation's primary workspace. Decided before any record is read. A record the caller may not see is 404 instead. | | 404 | `NotFoundError` | [`NOT_FOUND`](/errors/NOT_FOUND/) | The record does not exist, or the caller may not see it. The two are deliberately the same, so identifiers cannot be probed. | | 429 | `TooManyRequestsError` | [`RATE_LIMITED`](/errors/RATE_LIMITED/) | Too many requests; wait for `Retry-After` and try again. Retried by the SDKs. | | 500 | `InternalError` | [`INTERNAL`](/errors/INTERNAL/) | An unexpected server failure. Not retried by the SDKs. | | 503 | `ServiceUnavailableError` | [`SERVICE_UNAVAILABLE`](/errors/SERVICE_UNAVAILABLE/) | A dependency is briefly unavailable, or the request's audit event could not be recorded; wait for `Retry-After` and try again. Retried by the SDKs. | ## Examples ### Read the draft before editing it ```ts tab="TypeScript" import { Caitlyn } from '@caitlyn-ai/sdk'; const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! } }); const result = await caitlyn.versions.get({ agentId: 'GHT7YRWXPL', version: 'draft' }); console.log(result); ``` ```python tab="Python" import os from caitlyn import Caitlyn from caitlyn.auth import Token caitlyn = Caitlyn(workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"])) result = caitlyn.versions.get(agent_id="GHT7YRWXPL", version="draft") print(result) ``` ```go tab="Go" package main import ( "context" "encoding/json" "fmt" "log" "os" caitlyn "github.com/caitlyn-ai/caitlyn-go" ) func main() { ctx := context.Background() client, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) if err != nil { log.Fatal(err) } result, err := client.Versions.Get(ctx, &caitlyn.GetAgentVersionInput{ AgentID: "GHT7YRWXPL", Version: "draft", }) if err != nil { log.Fatal(err) } out, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(out)) } ``` ```rust tab="Rust" use caitlyn_sdk::{Auth, Client, GetAgentVersionInput}; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; let result = client .versions() .get(GetAgentVersionInput { agent_id: "GHT7YRWXPL".into(), version: "draft".into(), }) .await?; println!("{result:#?}"); Ok(()) } ``` The HTTP exchange: ```http GET /api/v3/agents/GHT7YRWXPL/versions/draft HTTP/1.1 Host: your-company.getcaitlyn.ai Authorization: Bearer ``` ```http HTTP/1.1 200 ETag: "draft-41" Content-Type: application/json { "agentId": "GHT7YRWXPL", "name": "draft", "matchesProduction": false, "createdAt": "2026-11-02T03:04:05.000Z", "configuration": { "identity": "You are the HR assistant for Harbour Freight staff.", "instructions": "Answer leave and pay questions from the HR policy knowledge base.", "model": "claude-sonnet-5-5", "knowledgeBases": [ { "knowledgeBaseId": "KB4T7YQ2WL", "numberOfResults": 8, "minimumScore": 0.3 } ] } } ``` ### A version name that does not exist, or that the caller may not see ```ts tab="TypeScript" import { Caitlyn, NotFoundError } from '@caitlyn-ai/sdk'; const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! } }); try { const result = await caitlyn.versions.get({ agentId: 'GHT7YRWXPL', version: 'v99' }); console.log(result); } catch (err) { if (!(err instanceof NotFoundError)) throw err; console.error(err.status, err.code, err.detail, err.requestId); } ``` ```python tab="Python" import os from caitlyn import Caitlyn, NotFoundError from caitlyn.auth import Token caitlyn = Caitlyn(workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"])) try: result = caitlyn.versions.get(agent_id="GHT7YRWXPL", version="v99") print(result) except NotFoundError as err: print(err.status, err.code, err.detail, err.request_id) ``` ```go tab="Go" package main import ( "context" "encoding/json" "errors" "fmt" "log" "os" caitlyn "github.com/caitlyn-ai/caitlyn-go" ) func main() { ctx := context.Background() client, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) if err != nil { log.Fatal(err) } result, err := client.Versions.Get(ctx, &caitlyn.GetAgentVersionInput{ AgentID: "GHT7YRWXPL", Version: "v99", }) if err != nil { var apiErr *caitlyn.NotFoundError if errors.As(err, &apiErr) { fmt.Println(apiErr.Status, apiErr.Code, apiErr.Detail, apiErr.RequestID) return } log.Fatal(err) } out, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(out)) } ``` ```rust tab="Rust" use caitlyn_sdk::{Auth, Client, GetAgentVersionInput}; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; let outcome: Result<(), caitlyn_sdk::Error> = async { let result = client .versions() .get(GetAgentVersionInput { agent_id: "GHT7YRWXPL".into(), version: "v99".into(), }) .await?; println!("{result:#?}"); Ok(()) } .await; match outcome { Err(caitlyn_sdk::Error::NotFound(err)) => eprintln!("{} {} {:?} {:?}", err.status, err.code, err.detail, err.request_id), other => other?, } Ok(()) } ``` The HTTP exchange: ```http GET /api/v3/agents/GHT7YRWXPL/versions/v99 HTTP/1.1 Host: your-company.getcaitlyn.ai Authorization: Bearer ``` ```http HTTP/1.1 404 Content-Type: application/problem+json { "type": "https://build.caitlyn.ai/errors/NOT_FOUND", "title": "Not found", "status": 404, "code": "NOT_FOUND", "detail": "Agent GHT7YRWXPL has no version v99.", "requestId": "01JC9Q2W7R4M0H2T6N1S8V3B5X" } ``` # UpdateDraftVersion {#_top} Edits the draft with a partial update of its configuration; `If-Match` is required. Members that are absent stay unchanged, nested objects are merged and lists are replaced whole. To return a member to its default, name it in `clear`; naming a member that is not clearable, or one the same request also sets, is refused with 400 `VALIDATION_FAILED`. Returns the whole draft and its new ETag. > **Not yet available.** The SDKs have this operation, but no Caitlyn release serves it yet: a call > gets 404. This page describes how it will work. [GetAgentVersion](/reference/get-agent-version/) is the one v3 > operation served today. | | | | --- | --- | | HTTP | `PATCH /api/v3/agents/{agentId}/versions/draft` | | TypeScript | `caitlyn.versions.updateDraft(agentId, { … })` | | Python | `caitlyn.versions.update_draft(agent_id, …)` | | Go | `client.Versions.UpdateDraft(ctx, agentID, &caitlyn.UpdateDraftVersionInput{…})` | | Rust | `client.versions().update_draft(agent_id, UpdateDraftVersionInput { … })` | | Stability | Generally available | | Since | Caitlyn 5.115.0 | | Retried by the SDKs | Yes, on 429, 503 and connection failures ([Retries](/retries/)) | ## Request Member names are shown as they are in TypeScript and on the wire. Python and Rust use snake case (`knowledge_base_id`) and Go uses exported names (`KnowledgeBaseID`). `agentId` is the method's first argument rather than an input field. `ifMatch` is a call option (TypeScript `{ ifMatch }`, Go `caitlyn.WithIfMatch`, Rust `.if_match(…)`; Python takes `if_match=`). | Member | Type | Sent in | Description | | --- | --- | --- | --- | | `agentId` (required) | string | path | The agent whose draft is edited. 1 to 128 characters. | | `ifMatch` (required) | string | header `If-Match` | The draft's current ETag, from a Get or an earlier update. A stale value is refused with 412; a missing one with 428. 2 to 256 characters. | | `identity` | string | body | The agent's identity statement, at most 4,000 characters. 0 to 4000 characters. | | `instructions` | string | body | The agent's instructions, at most 100,000 characters. 0 to 100000 characters. | | `model` | string | body | The model the agent answers with, from the agent models list. 1 to 256 characters. | | `knowledgeBases` | list of [KnowledgeBaseBinding](/reference/types/knowledge-base-binding/) | body | The knowledge bases the agent searches. Replaces the whole list. At most 50 items. | | `attachments` | [AttachmentSettings](/reference/types/attachment-settings/) | body | Whether people may attach files in chat, and how many. | | `contentSafety` | [ContentSafety](/reference/types/content-safety/) | body | The content safety setting applied to uploads and answers. | | `deniedDocuments` | boolean | body | When true, the answer stream tells the caller which documents were withheld from them. Defaults to false. | | `clear` | list of [ClearableDraftField](/reference/types/clearable-draft-field/) | body | Draft members to return to their default. | ## Response Status 200. | Member | Type | Sent in | Description | | --- | --- | --- | --- | | `requestId` | string | header `Caitlyn-Request-Id` | The request's identifier. The same value is in every error body; quote it to support. 1 to 128 characters. | | `apiVersion` | string | header `Caitlyn-Api-Version` | The server's model version, for example `5.120.0`. The SDKs keep the latest value they have seen and refuse to call operations or send members newer than it. 5 to 32 characters. Pattern `^[0-9]+\.[0-9]+\.[0-9]+$`. | | `etag` (required) | string | header `ETag` | The version's current ETag; send it as `If-Match` to change it. 2 to 256 characters. | | `agentId` (required) | string | body | The agent the version belongs to. 1 to 128 characters. | | `name` (required) | string | body | `draft`, `production` or the version's name. 1 to 63 characters. Pattern `^[a-z0-9][a-z0-9._-]{0,62}$`. | | `source` | [VersionSource](/reference/types/version-source/) | body | How a pinned or published version was made. Absent on the draft. | | `basedOn` | string | body | The version this one was made from, where there is one. 1 to 63 characters. Pattern `^[a-z0-9][a-z0-9._-]{0,62}$`. | | `matchesProduction` | boolean | body | True when this version's configuration is the one production currently answers with. Defaults to false. | | `createdAt` (required) | timestamp (RFC 3339) | body | When the version was made; for the draft, when it was first created. | | `note` | string | body | The note written when the version was pinned or published. 0 to 2000 characters. | | `configuration` (required) | [AgentConfiguration](/reference/types/agent-configuration/) | body | The version's configuration. | Every result also keeps the JSON it was read from, unknown fields included, as `raw`, and the response metadata (HTTP status, request ID, ETag). ## Permissions - **Permission:** `agents.update`. - **Checked on:** the agent named by `agentId`. The draft is checked too, so the caller also needs the agent's version access grant. - **Who can call it:** people (signed in, or a personal access token), API clients (machine sign-in). The checks run in this order: who is calling, the workspace and the permission (each refused with 403), then the record (404, which is also what a record the caller may not see returns). [Errors](/errors/) explains why. ## Errors The SDKs raise the error class for the response's HTTP status, whether or not the operation lists it ([Errors](/errors/)). The statuses this operation returns: | Status | Error | Codes | When | | --- | --- | --- | --- | | 400 | `ValidationError` | [`VALIDATION_FAILED`](/errors/VALIDATION_FAILED/), [`UNKNOWN_FIELD`](/errors/UNKNOWN_FIELD/), [`UNKNOWN_FILTER`](/errors/UNKNOWN_FILTER/), [`UNKNOWN_SORT`](/errors/UNKNOWN_SORT/), [`INVALID_CURSOR`](/errors/INVALID_CURSOR/) | The request is malformed: a member is missing, the wrong type or out of range; it has a member, filter or sort field the operation does not define; or its cursor is not valid for this list. | | 401 | `UnauthenticatedError` | [`UNAUTHENTICATED`](/errors/UNAUTHENTICATED/) | No credential was sent, or it is expired or not valid. | | 403 | `PermissionDeniedError` | [`PERMISSION_DENIED`](/errors/PERMISSION_DENIED/), [`NOT_AVAILABLE_TO_CREDENTIALS`](/errors/NOT_AVAILABLE_TO_CREDENTIALS/), [`PRIMARY_WORKSPACE_REQUIRED`](/errors/PRIMARY_WORKSPACE_REQUIRED/) | The caller may not call this operation: it lacks the permission, its credential type may not call it, or the operation is served only on the organisation's primary workspace. Decided before any record is read. A record the caller may not see is 404 instead. | | 404 | `NotFoundError` | [`NOT_FOUND`](/errors/NOT_FOUND/) | The record does not exist, or the caller may not see it. The two are deliberately the same, so identifiers cannot be probed. | | 412 | `PreconditionFailedError` | [`PRECONDITION_FAILED`](/errors/PRECONDITION_FAILED/) | `If-Match` does not match the resource's current ETag: it changed since the caller read it. | | 428 | `PreconditionRequiredError` | [`PRECONDITION_REQUIRED`](/errors/PRECONDITION_REQUIRED/) | The update sent no `If-Match` header; read the resource and send its ETag. | | 429 | `TooManyRequestsError` | [`RATE_LIMITED`](/errors/RATE_LIMITED/) | Too many requests; wait for `Retry-After` and try again. Retried by the SDKs. | | 500 | `InternalError` | [`INTERNAL`](/errors/INTERNAL/) | An unexpected server failure. Not retried by the SDKs. | | 503 | `ServiceUnavailableError` | [`SERVICE_UNAVAILABLE`](/errors/SERVICE_UNAVAILABLE/) | A dependency is briefly unavailable, or the request's audit event could not be recorded; wait for `Retry-After` and try again. Retried by the SDKs. | ## Examples ### Change the instructions and clear the identity ```ts tab="TypeScript" import { Caitlyn } from '@caitlyn-ai/sdk'; const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! } }); const result = await caitlyn.versions.updateDraft('GHT7YRWXPL', { instructions: 'Answer leave and pay questions. Quote the policy clause you relied on.', clear: ['identity'], }, { ifMatch: '"draft-41"' }); console.log(result); ``` ```python tab="Python" import os from caitlyn import Caitlyn from caitlyn.auth import Token caitlyn = Caitlyn(workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"])) result = caitlyn.versions.update_draft( "GHT7YRWXPL", if_match='"draft-41"', instructions="Answer leave and pay questions. Quote the policy clause you relied on.", clear=["identity"], ) print(result) ``` ```go tab="Go" package main import ( "context" "encoding/json" "fmt" "log" "os" caitlyn "github.com/caitlyn-ai/caitlyn-go" ) func main() { ctx := context.Background() client, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) if err != nil { log.Fatal(err) } result, err := client.Versions.UpdateDraft(ctx, "GHT7YRWXPL", &caitlyn.UpdateDraftVersionInput{ Instructions: caitlyn.Ptr("Answer leave and pay questions. Quote the policy clause you relied on."), Clear: []caitlyn.ClearableDraftField{caitlyn.ClearableDraftFieldIdentity}, }, caitlyn.WithIfMatch("\"draft-41\"")) if err != nil { log.Fatal(err) } out, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(out)) } ``` ```rust tab="Rust" use caitlyn_sdk::{Auth, ClearableDraftField, Client, UpdateDraftVersionInput}; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; let result = client .versions() .update_draft("GHT7YRWXPL", UpdateDraftVersionInput { instructions: Some("Answer leave and pay questions. Quote the policy clause you relied on.".into()), clear: Some(vec![ClearableDraftField::Identity]), ..Default::default() }) .if_match("\"draft-41\"") .await?; println!("{result:#?}"); Ok(()) } ``` The HTTP exchange: ```http PATCH /api/v3/agents/GHT7YRWXPL/versions/draft HTTP/1.1 Host: your-company.getcaitlyn.ai Authorization: Bearer If-Match: "draft-41" Content-Type: application/json { "instructions": "Answer leave and pay questions. Quote the policy clause you relied on.", "clear": [ "identity" ] } ``` ```http HTTP/1.1 200 ETag: "draft-42" Content-Type: application/json { "agentId": "GHT7YRWXPL", "name": "draft", "matchesProduction": false, "createdAt": "2026-11-02T03:04:05.000Z", "configuration": { "instructions": "Answer leave and pay questions. Quote the policy clause you relied on.", "model": "claude-sonnet-5-5" } } ``` ### The draft changed since it was read ```ts tab="TypeScript" import { Caitlyn, PreconditionFailedError } from '@caitlyn-ai/sdk'; const caitlyn = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! } }); try { const result = await caitlyn.versions.updateDraft('GHT7YRWXPL', { instructions: 'Answer leave questions.', }, { ifMatch: '"draft-40"' }); console.log(result); } catch (err) { if (!(err instanceof PreconditionFailedError)) throw err; console.error(err.status, err.code, err.detail, err.requestId); } ``` ```python tab="Python" import os from caitlyn import Caitlyn, PreconditionFailedError from caitlyn.auth import Token caitlyn = Caitlyn(workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"])) try: result = caitlyn.versions.update_draft( "GHT7YRWXPL", if_match='"draft-40"', instructions="Answer leave questions.", ) print(result) except PreconditionFailedError as err: print(err.status, err.code, err.detail, err.request_id) ``` ```go tab="Go" package main import ( "context" "encoding/json" "errors" "fmt" "log" "os" caitlyn "github.com/caitlyn-ai/caitlyn-go" ) func main() { ctx := context.Background() client, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) if err != nil { log.Fatal(err) } result, err := client.Versions.UpdateDraft(ctx, "GHT7YRWXPL", &caitlyn.UpdateDraftVersionInput{ Instructions: caitlyn.Ptr("Answer leave questions."), }, caitlyn.WithIfMatch("\"draft-40\"")) if err != nil { var apiErr *caitlyn.PreconditionFailedError if errors.As(err, &apiErr) { fmt.Println(apiErr.Status, apiErr.Code, apiErr.Detail, apiErr.RequestID) return } log.Fatal(err) } out, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(out)) } ``` ```rust tab="Rust" use caitlyn_sdk::{Auth, Client, UpdateDraftVersionInput}; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?; let outcome: Result<(), caitlyn_sdk::Error> = async { let result = client .versions() .update_draft("GHT7YRWXPL", UpdateDraftVersionInput { instructions: Some("Answer leave questions.".into()), ..Default::default() }) .if_match("\"draft-40\"") .await?; println!("{result:#?}"); Ok(()) } .await; match outcome { Err(caitlyn_sdk::Error::PreconditionFailed(err)) => eprintln!("{} {} {:?} {:?}", err.status, err.code, err.detail, err.request_id), other => other?, } Ok(()) } ``` The HTTP exchange: ```http PATCH /api/v3/agents/GHT7YRWXPL/versions/draft HTTP/1.1 Host: your-company.getcaitlyn.ai Authorization: Bearer If-Match: "draft-40" Content-Type: application/json { "instructions": "Answer leave questions." } ``` ```http HTTP/1.1 412 Content-Type: application/problem+json { "type": "https://build.caitlyn.ai/errors/PRECONDITION_FAILED", "title": "The resource has changed", "status": 412, "code": "PRECONDITION_FAILED", "detail": "The draft of agent GHT7YRWXPL changed since you read it. Read it again and reapply your change.", "requestId": "01JC9Q2W7R4M0H2T6N1S8V3B5Y" } ``` # AgentConfiguration {#_top} An agent version's configuration. | Member | Type | Description | | --- | --- | --- | | `identity` | string | The agent's identity statement. Absent when it was never set or was cleared. 0 to 4000 characters. | | `instructions` (required) | string | The agent's instructions. Empty when none are set. 0 to 100000 characters. | | `model` | string | The model the agent answers with. 1 to 256 characters. | | `knowledgeBases` | list of [KnowledgeBaseBinding](/reference/types/knowledge-base-binding/) | The knowledge bases the agent searches, in no particular order. At most 50 items. | | `attachments` | [AttachmentSettings](/reference/types/attachment-settings/) | Whether people may attach files in chat, and how many. | | `contentSafety` | [ContentSafety](/reference/types/content-safety/) | The content safety setting applied to uploads and answers. | | `deniedDocuments` | boolean | When true, the answer stream tells the caller which documents were withheld from them. Defaults to false. | Unknown members a newer server sends are kept in the object's `raw`. Used by [GetAgentVersion](/reference/get-agent-version/), [UpdateDraftVersion](/reference/update-draft-version/). # AttachmentSettings {#_top} Chat attachment settings. | Member | Type | Description | | --- | --- | --- | | `enabled` | boolean | Whether people may attach files in chat. Defaults to false. | | `maxFiles` | integer | The most files one message may carry, from 1 to 20. | Unknown members a newer server sends are kept in the object's `raw`. Used by [GetAgentVersion](/reference/get-agent-version/), [UpdateDraftVersion](/reference/update-draft-version/). # Citation {#_top} A passage the answer cites. | Member | Type | Description | | --- | --- | --- | | `number` (required) | integer | The citation's number in the answer text, starting at 1. | | `documentId` | string | The cited document. 1 to 128 characters. | | `knowledgeBaseId` | string | The knowledge base the document is in. 1 to 128 characters. | | `documentName` | string | The cited document's name. 1 to 1024 characters. | | `pageNumber` | integer | The page the passage is on, starting at 1, for paged documents. | Unknown members a newer server sends are kept in the object's `raw`. Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # CitationAdded {#_top} The data of `citation.added`. | Member | Type | Description | | --- | --- | --- | | `citation` (required) | [Citation](/reference/types/citation/) | The passage the answer now cites. | Unknown members a newer server sends are kept in the object's `raw`. Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # ClearableDraftField {#_top} The draft members that can be cleared. Open: an older SDK may send a newer value as a string. A string. The SDKs keep a value they do not know yet as the string itself (Python: an unknown member carrying it), so a newer server never breaks an older SDK. | Value | Description | | --- | --- | | `identity` | The agent's identity statement. | Used by [UpdateDraftVersion](/reference/update-draft-version/). # ContentSafety {#_top} The content safety setting on a version. | Member | Type | Description | | --- | --- | --- | | `guardrailId` | string | The guardrail applied, from the guardrails list. Absent means none. 1 to 128 characters. | Unknown members a newer server sends are kept in the object's `raw`. Used by [GetAgentVersion](/reference/get-agent-version/), [UpdateDraftVersion](/reference/update-draft-version/). # ContextItem {#_top} One item of structured context, for example the page or record the person is looking at. | Member | Type | Description | | --- | --- | --- | | `type` (required) | string | What kind of thing the item describes, for example `page`. 1 to 64 characters. | | `label` (required) | string | A short label the agent can refer to. 1 to 256 characters. | | `uri` | string | Where the item lives, when it has an address. 1 to 4096 characters. | | `data` | JSON | The item's data, as JSON. Counts towards the 16 KB limit. | Unknown members a newer server sends are kept in the object's `raw`. Used by [SendMessage](/reference/send-message/). # ConversationEvent {#_top} The events on a conversation's answer stream. The SSE `event:` name is each member's `@jsonName`. Clients skip event names they do not know. Exactly one of these, named by the SSE `event:` line. The SDKs skip an event name they do not know. | Event | Data | Visibility | Description | | --- | --- | --- | --- | | `message.started` | [MessageStarted](/reference/types/message-started/) | public | The answer has started. | | `phase.changed` | [PhaseChanged](/reference/types/phase-changed/) | public | The agent moved to another phase of its workflow. | | `progress.updated` | [ProgressUpdated](/reference/types/progress-updated/) | public | A progress step started, changed or finished. | | `text.delta` | [TextDelta](/reference/types/text-delta/) | public | A fragment of answer text. The one recorded exception to the noun.past_tense naming rule. | | `citation.added` | [CitationAdded](/reference/types/citation-added/) | public | A citation was added to the answer. | | `tool.started` | [ToolStarted](/reference/types/tool-started/) | public | A tool call started. | | `tool.finished` | [ToolFinished](/reference/types/tool-finished/) | public | A tool call finished. | | `input.requested` | [InputRequested](/reference/types/input-requested/) | public | The agent asked the person for input through a widget. | | `message.completed` | [MessageCompleted](/reference/types/message-completed/) | public | The answer is complete. Ends the stream. | | `message.failed` | [MessageFailed](/reference/types/message-failed/) | public | The answer failed. Ends the stream; its data is a problem details object. | Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # Document {#_top} One document in a knowledge base, stable across new uploads of new bytes. | Member | Type | Description | | --- | --- | --- | | `id` (required) | string | The document's identifier. 1 to 128 characters. | | `knowledgeBaseId` (required) | string | The knowledge base the document is in. 1 to 128 characters. | | `name` (required) | string | The document's name, as people see it. 1 to 1024 characters. | | `folder` (required) | string | The folder the document is in. 1 to 1024 characters. Pattern `^(/|([^/]+/)+)$`. | | `status` (required) | [DocumentStatus](/reference/types/document-status/) | Where the document is in its lifecycle. | | `stage` | string | While `processing`, what the server is doing, for display only. 1 to 128 characters. | | `contentType` | string | The media type of the current bytes. 3 to 255 characters. | | `byteSize` | long | The size of the current bytes, in bytes. | | `sha256` | string | The base64-encoded SHA-256 of the current bytes. 44 to 44 characters. Pattern `^[A-Za-z0-9+/]{43}=$`. | | `createdAt` (required) | timestamp (RFC 3339) | When the document was created. | | `updatedAt` (required) | timestamp (RFC 3339) | When the document or its bytes last changed. | | `partialFailures` | list of [PartialFailure](/reference/types/partial-failure/) | Enrichments that did not complete (a picture description, metadata extraction, a table). They never fail the document. At most the first 100. At most 100 items. | Unknown members a newer server sends are kept in the object's `raw`. Used by [CreateDocument](/reference/create-document/), [ListDocuments](/reference/list-documents/). # DocumentStatus {#_top} Where a document is in its lifecycle. Open: new values may be added. `indexed`, `partially_indexed` and `failed` are final. A string. The SDKs keep a value they do not know yet as the string itself (Python: an unknown member carrying it), so a newer server never breaks an older SDK. | Value | Description | | --- | --- | | `pending` | Created; processing has not started. | | `processing` | Being processed. `stage` says what the server is doing. | | `indexed` | Searchable. Final. | | `partially_indexed` | Searchable, but some enrichments did not complete; see `partialFailures`. Final. | | `failed` | The document's content could not be processed. Final. | | `deleting` | Being deleted. | Used by [CreateDocument](/reference/create-document/), [ListDocuments](/reference/list-documents/). # FieldError {#_top} One problem with one request member or query parameter. | Member | Type | Description | | --- | --- | --- | | `pointer` | string | A JSON Pointer (RFC 6901) into the request body, for example `/context/0/label`. Absent when the problem is with a query parameter. 1 to 4096 characters. | | `parameter` | string | The query parameter at fault, for example `filter[staus][eq]`. Absent when the problem is in the body. 1 to 4096 characters. | | `code` (required) | string | The code for this field's problem, from the same catalogue as the response's own code. Pattern `^[A-Z][A-Z0-9_]{0,62}$`. | | `detail` | string | What is wrong with the field, written for the integrator. 1 to 4096 characters. | Unknown members a newer server sends are kept in the object's `raw`. Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # InputRequested {#_top} The data of `input.requested`. | Member | Type | Description | | --- | --- | --- | | `widget` | JSON | The widget the agent asks the person to fill in. | Unknown members a newer server sends are kept in the object's `raw`. Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # KnowledgeBaseBinding {#_top} One knowledge base an agent searches, and how. | Member | Type | Description | | --- | --- | --- | | `knowledgeBaseId` (required) | string | The knowledge base searched. 1 to 128 characters. | | `numberOfResults` | integer | How many passages to retrieve per search, from 1 to 100. Absent means the server default. | | `minimumScore` | float | The lowest relevance score a passage needs to be used, from 0 to 1. Absent means the server default. | Unknown members a newer server sends are kept in the object's `raw`. Used by [GetAgentVersion](/reference/get-agent-version/), [UpdateDraftVersion](/reference/update-draft-version/). # Message {#_top} A message in a conversation. | Member | Type | Description | | --- | --- | --- | | `id` (required) | string | The message's identifier. 1 to 128 characters. | | `role` (required) | [MessageRole](/reference/types/message-role/) | Who wrote the message. | | `text` (required) | string | The message text. For an answer, the whole text the deltas built up. 1 to 32000 characters. | | `agentVersion` | string | The agent version that wrote an answer. 1 to 63 characters. Pattern `^[a-z0-9][a-z0-9._-]{0,62}$`. | | `citations` | list of [Citation](/reference/types/citation/) | The passages an answer cites, in citation number order. | | `createdAt` (required) | timestamp (RFC 3339) | When the message was written. | Unknown members a newer server sends are kept in the object's `raw`. Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # MessageCompleted {#_top} The data of `message.completed`. | Member | Type | Description | | --- | --- | --- | | `message` (required) | [Message](/reference/types/message/) | The complete answer message. | Unknown members a newer server sends are kept in the object's `raw`. Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # MessageFailed {#_top} The data of `message.failed`: a problem details object, exactly as an error response body. | Member | Type | Description | | --- | --- | --- | | `type` (required) | string | A stable URI for the code: its page in the public error reference, `https://build.caitlyn.ai/errors/{code}`. 1 to 2048 characters. | | `title` (required) | string | A short, human-readable summary of the problem, the same for every occurrence of the code. 1 to 4096 characters. | | `status` (required) | integer | The HTTP status code, repeated from the response. | | `code` (required) | string | The stable code, for example `NOT_FOUND`. Once shipped a code keeps its meaning and is never renamed; callers match on it. Pattern `^[A-Z][A-Z0-9_]{0,62}$`. | | `detail` | string | What went wrong in this occurrence, written for the integrator. Never holds stack traces, storage keys or another person's data. 1 to 4096 characters. | | `requestId` (required) | string | The request's identifier, the same value as the `Caitlyn-Request-Id` header. Quote it to support. 1 to 128 characters. | | `errors` | list of [FieldError](/reference/types/field-error/) | Field-level problems, one per member or parameter at fault. Present only on 400 `VALIDATION_FAILED`. | Unknown members a newer server sends are kept in the object's `raw`. Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # MessageRole {#_top} Who wrote a message. Open: new values may be added. A string. The SDKs keep a value they do not know yet as the string itself (Python: an unknown member carrying it), so a newer server never breaks an older SDK. | Value | Description | | --- | --- | | `user` | The person or client that sent it. | | `assistant` | The agent. | Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # MessageStarted {#_top} The data of `message.started`. | Member | Type | Description | | --- | --- | --- | | `messageId` (required) | string | The answer message's identifier. 1 to 128 characters. | | `agentVersion` | string | The agent version answering. 1 to 63 characters. Pattern `^[a-z0-9][a-z0-9._-]{0,62}$`. | | `phase` | string | The workflow phase the answer starts in, where the agent has phases. 1 to 256 characters. | Unknown members a newer server sends are kept in the object's `raw`. Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # Operation {#_top} Background work that continues after the request that started it. Read it, wait on it or watch it; never start the work again to find out how it went. | Member | Type | Description | | --- | --- | --- | | `id` (required) | string | The Operation's identifier. 1 to 128 characters. | | `kind` (required) | [OperationKind](/reference/types/operation-kind/) | What kind of work it is. | | `status` (required) | [OperationStatus](/reference/types/operation-status/) | Where the work is. | | `createdAt` (required) | timestamp (RFC 3339) | When the work was requested. | Unknown members a newer server sends are kept in the object's `raw`. Used by [CreateDocument](/reference/create-document/). # OperationKind {#_top} What kind of work an Operation is. Open: one value per kind of work, added as kinds are modelled. A string. The SDKs keep a value they do not know yet as the string itself (Python: an unknown member carrying it), so a newer server never breaks an older SDK. | Value | Description | | --- | --- | | `document_ingestion` | A document's ingestion: reading, enriching and indexing its content. | Used by [CreateDocument](/reference/create-document/). # OperationStatus {#_top} Where an Operation is. Open: new values may be added. `succeeded`, `succeeded_with_failures`, `failed` and `cancelled` are final. A string. The SDKs keep a value they do not know yet as the string itself (Python: an unknown member carrying it), so a newer server never breaks an older SDK. | Value | Description | | --- | --- | | `queued` | Waiting to start. | | `running` | Running. | | `succeeded` | Finished with no failures. Final. | | `succeeded_with_failures` | Finished, but some enrichments did not complete; see `partialFailures`. Final. | | `failed` | The core work failed; see `error`. Final. | | `cancelled` | Cancelled before it finished. Final. | Used by [CreateDocument](/reference/create-document/). # PartialFailure {#_top} An enrichment that did not complete. It never fails the document. | Member | Type | Description | | --- | --- | --- | | `targetId` | string | The part of the document the enrichment was for, where there is one. 1 to 128 characters. | | `code` (required) | string | What went wrong, as a stable code. Pattern `^[A-Z][A-Z0-9_]{0,62}$`. | | `detail` | string | What went wrong, written for the integrator. 1 to 4096 characters. | Unknown members a newer server sends are kept in the object's `raw`. Used by [CreateDocument](/reference/create-document/), [ListDocuments](/reference/list-documents/). # PhaseChanged {#_top} The data of `phase.changed`. | Member | Type | Description | | --- | --- | --- | | `phase` (required) | string | The phase the agent is now in. 1 to 256 characters. | | `previousPhase` | string | The phase it left. 1 to 256 characters. | Unknown members a newer server sends are kept in the object's `raw`. Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # ProgressStatus {#_top} A progress step's status. Open: new values may be added. `succeeded` and `failed` are final. A string. The SDKs keep a value they do not know yet as the string itself (Python: an unknown member carrying it), so a newer server never breaks an older SDK. | Value | Description | | --- | --- | | `running` | The step is running. | | `succeeded` | The step finished. Final. | | `failed` | The step failed. Final. | Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # ProgressUpdated {#_top} The data of `progress.updated`. | Member | Type | Description | | --- | --- | --- | | `id` (required) | string | The step's identifier, unique within the message. 1 to 128 characters. | | `label` (required) | string | What the step is doing, for display. 1 to 256 characters. | | `details` | string | More detail for display. 0 to 4000 characters. | | `status` | [ProgressStatus](/reference/types/progress-status/) | The step's status. Absent means running. | | `section` | string | The section of the answer the step belongs to, for display. 1 to 256 characters. | Unknown members a newer server sends are kept in the object's `raw`. Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # TextDelta {#_top} The data of `text.delta`. | Member | Type | Description | | --- | --- | --- | | `delta` (required) | string | The next fragment of answer text, to append to what came before. 1 to 32000 characters. | Unknown members a newer server sends are kept in the object's `raw`. Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # ToolFinished {#_top} The data of `tool.finished`. | Member | Type | Description | | --- | --- | --- | | `toolCallId` (required) | string | The tool call's identifier. 1 to 128 characters. | | `toolName` (required) | string | The tool called. 1 to 256 characters. | | `status` (required) | [ToolStatus](/reference/types/tool-status/) | How the call ended. | | `result` | JSON | The call's result. Present only when the caller may read it. | | `durationMs` | long | How long the call took, in milliseconds. | Unknown members a newer server sends are kept in the object's `raw`. Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # ToolStarted {#_top} The data of `tool.started`. | Member | Type | Description | | --- | --- | --- | | `toolCallId` (required) | string | The tool call's identifier. 1 to 128 characters. | | `toolName` (required) | string | The tool called. 1 to 256 characters. | | `arguments` | JSON | The call's arguments. Present only when the caller may read them. | Unknown members a newer server sends are kept in the object's `raw`. Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # ToolStatus {#_top} How a tool call ended. Open: new values may be added. Every value is final. A string. The SDKs keep a value they do not know yet as the string itself (Python: an unknown member carrying it), so a newer server never breaks an older SDK. | Value | Description | | --- | --- | | `succeeded` | The call succeeded. | | `failed` | The call failed. | Used by [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/). # UploadStatus {#_top} Where an upload is in its lifecycle. Open: new values may be added. `consumed` and `expired` are final. A string. The SDKs keep a value they do not know yet as the string itself (Python: an unknown member carrying it), so a newer server never breaks an older SDK. | Value | Description | | --- | --- | | `awaiting_bytes` | Reserved; the bytes have not arrived. | | `received` | The bytes arrived and matched the checksum. | | `consumed` | Used by an operation. Final. | | `expired` | Not used before `expiresAt`. Final. | Used by [CreateUpload](/reference/create-upload/). # VersionSource {#_top} How a version was made. Open: new values may be added. A string. The SDKs keep a value they do not know yet as the string itself (Python: an unknown member carrying it), so a newer server never breaks an older SDK. | Value | Description | | --- | --- | | `pin` | Pinned from the draft, without changing production. | | `publish` | Published, which also made it production. | | `migration` | Carried over from the version model before v3. | | `import` | Created from an imported agent file. | Used by [GetAgentVersion](/reference/get-agent-version/), [UpdateDraftVersion](/reference/update-draft-version/). # VALIDATION_FAILED {#_top} The request is malformed: a member is missing, has the wrong type or is out of range. On an update, it is also what a member that is both set and named in `clear`, or a `clear` entry that cannot be cleared, gets. | | | | --- | --- | | HTTP status | 400 | | Error class | `ValidationError` in TypeScript and Python, `*caitlyn.ValidationError` in Go, `Error::Validation` in Rust | | Retried by the SDKs | No | ## What to do Read `errors` on the error: one entry per member or parameter at fault, with a JSON pointer to it, its own code and a description. Fix the request; sending it again unchanged fails the same way. ## The error body Every error is an RFC 9457 problem details object with `code` set to `VALIDATION_FAILED` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. # UNKNOWN_FIELD {#_top} The request has a member the operation does not define. v3 refuses unknown members rather than ignoring them, so a misspelt member is never silently dropped. | | | | --- | --- | | HTTP status | 400 | | Error class | `ValidationError` in TypeScript and Python, `*caitlyn.ValidationError` in Go, `Error::Validation` in Rust | | Retried by the SDKs | No | ## What to do Check the member names against the operation's reference page. An SDK only sends members it knows, so this usually means a hand-built request, or a member newer than the server (the SDKs check that before sending; see [Versions](/getting-started/#versions-and-older-servers)). ## The error body Every error is an RFC 9457 problem details object with `code` set to `UNKNOWN_FIELD` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. # UNKNOWN_FILTER {#_top} A list request has a filter, or a filter operator, the operation does not declare. | | | | --- | --- | | HTTP status | 400 | | Error class | `ValidationError` in TypeScript and Python, `*caitlyn.ValidationError` in Go, `Error::Validation` in Rust | | Retried by the SDKs | No | ## What to do Use only the filters the operation's reference page lists. Unlike v2, an unknown filter is refused rather than ignored. ## The error body Every error is an RFC 9457 problem details object with `code` set to `UNKNOWN_FILTER` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. # UNKNOWN_SORT {#_top} A list request asks to sort by a field the operation cannot sort by. | | | | --- | --- | | HTTP status | 400 | | Error class | `ValidationError` in TypeScript and Python, `*caitlyn.ValidationError` in Go, `Error::Validation` in Rust | | Retried by the SDKs | No | ## What to do Use one of the sortable fields the operation's reference page lists, with a leading `-` for descending order. ## The error body Every error is an RFC 9457 problem details object with `code` set to `UNKNOWN_SORT` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. # INVALID_CURSOR {#_top} The `cursor` is not one this list returned, or it belongs to a different list or different filters. | | | | --- | --- | | HTTP status | 400 | | Error class | `ValidationError` in TypeScript and Python, `*caitlyn.ValidationError` in Go, `Error::Validation` in Rust | | Retried by the SDKs | No | ## What to do Pass the `nextCursor` of the previous page of the same list, with the same filters and sort, or start again without a cursor. The SDKs' list iterators handle cursors for you ([Pagination](/pagination/)). ## The error body Every error is an RFC 9457 problem details object with `code` set to `INVALID_CURSOR` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. # UNAUTHENTICATED {#_top} No credential was sent, or it has expired or is not valid. | | | | --- | --- | | HTTP status | 401 | | Error class | `UnauthenticatedError` in TypeScript and Python, `*caitlyn.UnauthenticatedError` in Go, `Error::Unauthenticated` in Rust | | Retried by the SDKs | No | ## What to do In the `clientCredentials`, `oauth`, `awsIam` and `workloadIdentity` sign-in modes, the SDKs get a new token and send the call once more before raising this. If you still get it, check the credential itself: a revoked personal access token, a rotated client secret, or an `oauth` refresh token that was refused (sign the person in again). See [Authentication](/authentication/). ## The error body Every error is an RFC 9457 problem details object with `code` set to `UNAUTHENTICATED` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. # PERMISSION_DENIED {#_top} The caller does not have the permission the operation needs, or its kind of credential may not call the operation (some operations are for people only, or not for guests). | | | | --- | --- | | HTTP status | 403 | | Error class | `PermissionDeniedError` in TypeScript and Python, `*caitlyn.PermissionDeniedError` in Go, `Error::PermissionDenied` in Rust | | Retried by the SDKs | No | ## What to do The operation's reference page lists the permission and who may call it. Grant the permission to the person or API client, or give the API client a scope that includes it. A record the caller may not see is 404, not 403. ## The error body Every error is an RFC 9457 problem details object with `code` set to `PERMISSION_DENIED` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. # NOT_AVAILABLE_TO_CREDENTIALS {#_top} The operation issues or revokes credentials, so no token may call it: its permission belongs to no scope. A credential that could create credentials would escape its own revocation. | | | | --- | --- | | HTTP status | 403 | | Error class | `PermissionDeniedError` in TypeScript and Python, `*caitlyn.PermissionDeniedError` in Go, `Error::PermissionDenied` in Rust | | Retried by the SDKs | No | ## What to do Do this in the Caitlyn app while signed in, not with a token. ## The error body Every error is an RFC 9457 problem details object with `code` set to `NOT_AVAILABLE_TO_CREDENTIALS` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. # PRIMARY_WORKSPACE_REQUIRED {#_top} The operation acts on the whole organisation, and only the organisation's primary workspace serves it. | | | | --- | --- | | HTTP status | 403 | | Error class | `PermissionDeniedError` in TypeScript and Python, `*caitlyn.PermissionDeniedError` in Go, `Error::PermissionDenied` in Rust | | Retried by the SDKs | No | ## What to do Create the client for the organisation's primary workspace (its hostname) and call the operation there. ## The error body Every error is an RFC 9457 problem details object with `code` set to `PRIMARY_WORKSPACE_REQUIRED` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. # NOT_FOUND {#_top} The record does not exist, or the caller may not see it. The two are deliberately the same, so identifiers cannot be probed. For an agent version, it is also what someone without the agent's version access grant gets for the draft or a pinned version. | | | | --- | --- | | HTTP status | 404 | | Error class | `NotFoundError` in TypeScript and Python, `*caitlyn.NotFoundError` in Go, `Error::NotFound` in Rust | | Retried by the SDKs | No | ## What to do Check the identifier, and that the caller has access to the record (and, for the draft or a pinned version, the version access grant). ## The error body Every error is an RFC 9457 problem details object with `code` set to `NOT_FOUND` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. Operations that document this error: [ResumeConversationStream](/reference/resume-conversation-stream/), [SendMessage](/reference/send-message/), [CreateDocument](/reference/create-document/), [ListDocuments](/reference/list-documents/), [GetAgentVersion](/reference/get-agent-version/), [UpdateDraftVersion](/reference/update-draft-version/). Any operation can return it, though: the SDKs choose the class by HTTP status. # CONFLICT {#_top} The resource's current state does not allow the request: for example, an upload that has already been used, or a message sent while an answer to the previous one is still being written. | | | | --- | --- | | HTTP status | 409 | | Error class | `ConflictError` in TypeScript and Python, `*caitlyn.ConflictError` in Go, `Error::Conflict` in Rust | | Retried by the SDKs | No | ## What to do `detail` says what the conflict is. Wait for the other work to finish, or start from a fresh state (a new upload, for example), then try again. ## The error body Every error is an RFC 9457 problem details object with `code` set to `CONFLICT` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. Operations that document this error: [SendMessage](/reference/send-message/), [CreateDocument](/reference/create-document/), [CreateUpload](/reference/create-upload/). Any operation can return it, though: the SDKs choose the class by HTTP status. # ALREADY_EXISTS {#_top} A name that must be unique is already taken. | | | | --- | --- | | HTTP status | 409 | | Error class | `ConflictError` in TypeScript and Python, `*caitlyn.ConflictError` in Go, `Error::Conflict` in Rust | | Retried by the SDKs | No | ## What to do Choose another name, or fetch and use the record that already has it. ## The error body Every error is an RFC 9457 problem details object with `code` set to `ALREADY_EXISTS` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. Operations that document this error: [SendMessage](/reference/send-message/), [CreateDocument](/reference/create-document/), [CreateUpload](/reference/create-upload/). Any operation can return it, though: the SDKs choose the class by HTTP status. # IDEMPOTENCY_KEY_IN_USE {#_top} A request with the same `Idempotency-Key` is still being processed. | | | | --- | --- | | HTTP status | 409 | | Error class | `ConflictError` in TypeScript and Python, `*caitlyn.ConflictError` in Go, `Error::Conflict` in Rust | | Retried by the SDKs | No | ## What to do Wait, then send the request again with the same key: once the first request has finished, the server answers with its result instead of acting twice. ## The error body Every error is an RFC 9457 problem details object with `code` set to `IDEMPOTENCY_KEY_IN_USE` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. Operations that document this error: [SendMessage](/reference/send-message/), [CreateDocument](/reference/create-document/), [CreateUpload](/reference/create-upload/). Any operation can return it, though: the SDKs choose the class by HTTP status. # OPERATION_NOT_CANCELLABLE {#_top} Cancelling an Operation (a long-running task, such as a document ingestion) that cannot be cancelled. | | | | --- | --- | | HTTP status | 409 | | Error class | `ConflictError` in TypeScript and Python, `*caitlyn.ConflictError` in Go, `Error::Conflict` in Rust | | Retried by the SDKs | No | ## What to do No operation in the API returns this code yet; it is reserved for cancelling Operations. ## The error body Every error is an RFC 9457 problem details object with `code` set to `OPERATION_NOT_CANCELLABLE` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. Operations that document this error: [SendMessage](/reference/send-message/), [CreateDocument](/reference/create-document/), [CreateUpload](/reference/create-upload/). Any operation can return it, though: the SDKs choose the class by HTTP status. # PRECONDITION_FAILED {#_top} `If-Match` does not match the resource's current ETag: someone changed it after you read it. | | | | --- | --- | | HTTP status | 412 | | Error class | `PreconditionFailedError` in TypeScript and Python, `*caitlyn.PreconditionFailedError` in Go, `Error::PreconditionFailed` in Rust | | Retried by the SDKs | No | ## What to do Read the resource again, apply your change to what you read, and send it with the new ETag. Never retry with the old one. ## The error body Every error is an RFC 9457 problem details object with `code` set to `PRECONDITION_FAILED` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. Operations that document this error: [UpdateDraftVersion](/reference/update-draft-version/). Any operation can return it, though: the SDKs choose the class by HTTP status. # PAYLOAD_TOO_LARGE {#_top} The body, a file or the `context` of a sent message is over its documented limit. | | | | --- | --- | | HTTP status | 413 | | Error class | `PayloadTooLargeError` in TypeScript and Python, `*caitlyn.PayloadTooLargeError` in Go, `Error::PayloadTooLarge` in Rust | | Retried by the SDKs | No | ## What to do The limit is on the member's or operation's reference page. Send less: split the file, or trim the context. ## The error body Every error is an RFC 9457 problem details object with `code` set to `PAYLOAD_TOO_LARGE` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. Operations that document this error: [SendMessage](/reference/send-message/), [CreateUpload](/reference/create-upload/). Any operation can return it, though: the SDKs choose the class by HTTP status. # IDEMPOTENCY_KEY_REUSED {#_top} The `Idempotency-Key` was already used with a different request body. | | | | --- | --- | | HTTP status | 422 | | Error class | `UnprocessableContentError` in TypeScript and Python, `*caitlyn.UnprocessableContentError` in Go, `Error::UnprocessableContent` in Rust | | Retried by the SDKs | No | ## What to do Use a new key for a different request. The SDKs make a new key for every call and reuse it only for that call's retries, so this comes from passing your own key twice. ## The error body Every error is an RFC 9457 problem details object with `code` set to `IDEMPOTENCY_KEY_REUSED` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. Operations that document this error: [CreateDocument](/reference/create-document/), [CreateUpload](/reference/create-upload/). Any operation can return it, though: the SDKs choose the class by HTTP status. # PRECONDITION_REQUIRED {#_top} The update sent no `If-Match` header. | | | | --- | --- | | HTTP status | 428 | | Error class | `PreconditionRequiredError` in TypeScript and Python, `*caitlyn.PreconditionRequiredError` in Go, `Error::PreconditionRequired` in Rust | | Retried by the SDKs | No | ## What to do Read the resource, then send its ETag with the update (TypeScript `{ ifMatch }`, Python `if_match=`, Go `caitlyn.WithIfMatch`, Rust `.if_match(…)`). ## The error body Every error is an RFC 9457 problem details object with `code` set to `PRECONDITION_REQUIRED` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. Operations that document this error: [UpdateDraftVersion](/reference/update-draft-version/). Any operation can return it, though: the SDKs choose the class by HTTP status. # RATE_LIMITED {#_top} Too many requests. | | | | --- | --- | | HTTP status | 429 | | Error class | `TooManyRequestsError` in TypeScript and Python, `*caitlyn.TooManyRequestsError` in Go, `Error::TooManyRequests` in Rust | | Retried by the SDKs | Yes, for operations that are safe to repeat ([Retries](/retries/)) | ## What to do Wait for `Retry-After` (on the error as `retryAfterSeconds`) and try again. The SDKs do this for operations that are safe to repeat, up to their attempt limit ([Retries](/retries/)). ## The error body Every error is an RFC 9457 problem details object with `code` set to `RATE_LIMITED` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. # INTERNAL {#_top} An unexpected failure in Caitlyn. | | | | --- | --- | | HTTP status | 500 | | Error class | `InternalError` in TypeScript and Python, `*caitlyn.InternalError` in Go, `Error::Internal` in Rust | | Retried by the SDKs | No | ## What to do The SDKs do not retry it. Quote the `requestId` to Caitlyn support. ## The error body Every error is an RFC 9457 problem details object with `code` set to `INTERNAL` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field. # SERVICE_UNAVAILABLE {#_top} A service Caitlyn depends on is briefly unavailable, or the request's audit event could not be recorded (Caitlyn refuses a write it cannot audit). | | | | --- | --- | | HTTP status | 503 | | Error class | `ServiceUnavailableError` in TypeScript and Python, `*caitlyn.ServiceUnavailableError` in Go, `Error::ServiceUnavailable` in Rust | | Retried by the SDKs | Yes, for operations that are safe to repeat ([Retries](/retries/)) | ## What to do Wait for `Retry-After` (`retryAfterSeconds`) and try again. The SDKs do this for operations that are safe to repeat ([Retries](/retries/)). ## The error body Every error is an RFC 9457 problem details object with `code` set to `SERVICE_UNAVAILABLE` and `type` set to this page's address. `requestId` identifies the request; quote it to Caitlyn support. [Errors](/errors/) lists every field.