diff --git a/.repository-projection.json b/.repository-projection.json index 07c5c08..3231f86 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "api", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "c97cbb033be2d815b33abd51b9e1a9a37968066c", + "sourceSha": "d8f6c8e613bf0618e9df1f7963c85b6148fd8394", "destinationRepository": "dx-corp/api", - "priorProjectedBase": "0ec5c8b823ec3ba926670231988819898b114b79", - "definitionDigest": "706ab7174008f35b60af88a5efbfbd24e83d57686a5a3e6cd49243d205cb4344", + "priorProjectedBase": "c4c504e03e930343460a449680e6c2dbe9673a0d", + "definitionDigest": "4c1601bee99ccc8305013324608e9c146999b21427179d25fd342f69aebd37bd", "toolDigest": "0aae6000dbd0940f5a0af380463ccb5a83285eda", - "contentDigest": "110145f9d7c9f76ba0ddebe39c0c27de3e1b2f69034797f512196aa9eb111c11", + "contentDigest": "845eb92e1859d41b386f9e05b34abe05184f567bc1f9f9c53c46bb95b6951c45", "publicationEligible": true } diff --git a/docs/deixic-sdk.md b/docs/deixic-sdk.md index 96cb47f..57623b7 100644 --- a/docs/deixic-sdk.md +++ b/docs/deixic-sdk.md @@ -25,6 +25,12 @@ Python client with `pip install deixic-sdk`. Both clients default to `https://app.deixic.com` and accept an explicit base URL for another deployed environment. +New to the SDK? Start with the getting started guide for +[TypeScript](sdk/getting-started-typescript.md), +[Python](sdk/getting-started-python.md), or +[Go](sdk/getting-started-go.md). Each one walks from installation to a +completed task and an approval decision. + ## Safety contract - Choose the organization and workspace when constructing the client. Method diff --git a/docs/sdk/README.md b/docs/sdk/README.md new file mode 100644 index 0000000..aeb744e --- /dev/null +++ b/docs/sdk/README.md @@ -0,0 +1,70 @@ +# Deixic SDK getting started guides + +These guides take you from an empty project to a first completed Deixic task. +Each one covers installation, credentials, a setup check, submitting a task, +reading the result, and answering an approval request. + +| Language | Package | Guide | +| --- | --- | --- | +| TypeScript (Node.js 20+) | `@evalops/deixic-sdk` | [getting-started-typescript.md](getting-started-typescript.md) | +| Python (3.10+) | `deixic-sdk` | [getting-started-python.md](getting-started-python.md) | +| Go | `github.com/dx-corp/deixic-go` | [getting-started-go.md](getting-started-go.md) | + +The TypeScript and Python clients include task helpers that wait for a result, +save restart checkpoints, and reconnect bounded read requests. The Go module is +the generated Connect client for the same `deixicpublic.v1.DeixicPublicService` +contract, so a Go application supplies credentials, tenant scope, and polling +itself. + +For infrastructure-as-code, the +[Terraform provider](https://github.com/dx-corp/terraform-provider-deixic#readme) manages +Deixic business objects through the same public service. + +## Before you start + +You need: + +- a Deixic organization ID and workspace ID; +- a **Deixic tasks** API key, created in Settings → API access, stored in your + server-side secret manager; +- the ID of a channel (thread) in that workspace that the key can read and + write. The examples use `company`. + +Every guide reads its configuration from the same environment variables: + +```sh +export DEIXIC_API_KEY=... # Keep this out of source control and browser code. +export DEIXIC_ORGANIZATION_ID=org_... +export DEIXIC_WORKSPACE_ID=ws_... +# Optional. Defaults to https://app.deixic.com. +export DEIXIC_BASE_URL=https://app.deixic.com +``` + +## Concepts shared by every SDK + +- **Tenant scope.** The organization and workspace are fixed when you create + the client. Individual calls cannot replace them. +- **Idempotency keys.** Every mutation (submit, interrupt, respond, resolve) + takes a key that you choose. Use a new key for each new request, such as the + ID of the business event that triggered it. Reuse a key only to retry the + same request with the same body. +- **Acceptance is not completion.** Submitting a task returns an accepted turn. + Deixic then moves that turn through running, waiting, responded, and finally + completed, failed, or interrupted. Read or watch the turn until it reaches + one of those final states. +- **Waiting turns need a decision.** A turn that needs an approval or input + stays waiting until your application, acting for an authorized person, + responds. The SDKs never approve anything automatically. +- **Receipts are the record of actions.** A completed answer can reference + receipts. Check each receipt's state before you treat an external action as + done. +- **Cursors resume observation.** Event cursors are 64-bit integers. Save the + last one you processed and resume from it after a restart. If a page reports + `reset_required`, replace your local copy of the thread with the snapshot on + that page. +- **Mutations are not retried.** The clients do not resend a mutation after a + network or availability failure. Your application decides whether to retry + with the same idempotency key. + +The full list of supported operations and the safety contract is in +[deixic-sdk.md](../deixic-sdk.md). diff --git a/docs/sdk/getting-started-go.md b/docs/sdk/getting-started-go.md new file mode 100644 index 0000000..84ebc32 --- /dev/null +++ b/docs/sdk/getting-started-go.md @@ -0,0 +1,336 @@ +# Getting started with the Deixic Go client + +This guide builds a small Go program that submits a Deixic task, waits for the +answer, and answers an approval request. Read the [guide index](README.md) +first for the credentials and concepts every SDK shares. + +The Go module is the generated [Connect](https://connectrpc.com/docs/go/getting-started) +client for `deixicpublic.v1.DeixicPublicService`. Unlike the TypeScript and +Python SDKs, it has no task helpers: your program adds the credentials and +tenant headers, supplies idempotency keys, and polls for the result. The +client does not choose an organization, workspace, or idempotency key for you. + +## Requirements + +- Go 1.26 or later +- The `DEIXIC_*` environment variables described in the + [guide index](README.md#before-you-start) + +## 1. Install the module + +```sh +mkdir deixic-quickstart && cd deixic-quickstart +go mod init example.com/deixic-quickstart +go get github.com/dx-corp/deixic-go/deixicpublic/v1/deixicpublicv1connect connectrpc.com/connect +``` + +The generated client sends binary protobuf (`Content-Type: application/proto`) +with `Connect-Protocol-Version: 1`, which is what the Deixic API requires. Do +not switch it to JSON encoding. + +## 2. Write the program + +Create `main.go`. It builds a client, submits a task, polls the thread until +the accepted turn reaches a final or waiting state, and prints the answer: + +```go +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "os" + "time" + + "connectrpc.com/connect" + deixicv1 "github.com/dx-corp/deixic-go/deixicpublic/v1" + "github.com/dx-corp/deixic-go/deixicpublic/v1/deixicpublicv1connect" +) + +const channelID = "company" + +func main() { + ctx := context.Background() + scope := &deixicv1.Scope{ + OrganizationId: mustEnv("DEIXIC_ORGANIZATION_ID"), + WorkspaceId: mustEnv("DEIXIC_WORKSPACE_ID"), + } + baseURL := os.Getenv("DEIXIC_BASE_URL") + if baseURL == "" { + baseURL = "https://app.deixic.com" + } + + client := deixicpublicv1connect.NewDeixicPublicServiceClient( + http.DefaultClient, + baseURL, + connect.WithInterceptors(authInterceptor(mustEnv("DEIXIC_API_KEY"), scope)), + ) + + submitted, err := client.SubmitTask(ctx, connect.NewRequest(&deixicv1.SubmitTaskRequest{ + Scope: scope, + ThreadId: channelID, + Body: "Summarize the open changes in this workspace.", + // One key per business request. Reuse it only to retry this same request. + IdempotencyKey: "quickstart-2026-09-25-001", + })) + if err != nil { + log.Fatal(describe(err)) + } + turnID := submitted.Msg.GetAcceptedTurn().GetTurnId() + fmt.Println("accepted turn", turnID) + + turn, err := waitForTurn(ctx, client, scope, turnID, 2*time.Minute) + if err != nil { + log.Fatal(describe(err)) + } + + switch turn.GetState() { + case deixicv1.TurnState_TURN_STATE_COMPLETED: + answer, err := findMessage(ctx, client, scope, turn.GetAssistantMessageId()) + if err != nil { + log.Fatal(describe(err)) + } + fmt.Println(answer.GetBody()) + fmt.Println("receipts:", answer.GetReceiptIds()) + case deixicv1.TurnState_TURN_STATE_WAITING: + fmt.Println("needs a decision:", turn.GetWaitingReason()) + default: + fmt.Println(turn.GetState(), turn.GetTerminalError().GetCode()) + } +} + +// authInterceptor adds the bearer token and tenant headers to every call, +// including streaming calls such as WatchEvents. +func authInterceptor(apiKey string, scope *deixicv1.Scope) connect.Interceptor { + return &headerInterceptor{set: func(h http.Header) { + h.Set("Authorization", "Bearer "+apiKey) + h.Set("X-Organization-ID", scope.GetOrganizationId()) + h.Set("X-Workspace-ID", scope.GetWorkspaceId()) + }} +} + +type headerInterceptor struct{ set func(http.Header) } + +func (i *headerInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + i.set(req.Header()) + return next(ctx, req) + } +} + +func (i *headerInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { + return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn { + conn := next(ctx, spec) + i.set(conn.RequestHeader()) + return conn + } +} + +func (i *headerInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { + return next +} + +// waitForTurn polls the thread until the accepted turn completes, fails, is +// interrupted, or waits for a decision. A timeout stops polling only; the +// task keeps running in Deixic. +func waitForTurn( + ctx context.Context, + client deixicpublicv1connect.DeixicPublicServiceClient, + scope *deixicv1.Scope, + turnID string, + timeout time.Duration, +) (*deixicv1.TaskTurn, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + for { + thread, err := client.GetThread(ctx, connect.NewRequest(&deixicv1.GetThreadRequest{ + Scope: scope, + ThreadId: channelID, + Limit: 50, + })) + if err != nil { + return nil, err + } + for _, turn := range thread.Msg.GetTurns() { + if turn.GetTurnId() != turnID { + continue + } + switch turn.GetState() { + case deixicv1.TurnState_TURN_STATE_COMPLETED, + deixicv1.TurnState_TURN_STATE_FAILED, + deixicv1.TurnState_TURN_STATE_INTERRUPTED, + deixicv1.TurnState_TURN_STATE_WAITING: + return turn, nil + } + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(2 * time.Second): + } + } +} + +// findMessage pages through the thread to find the turn's final answer. +func findMessage( + ctx context.Context, + client deixicpublicv1connect.DeixicPublicServiceClient, + scope *deixicv1.Scope, + messageID string, +) (*deixicv1.TaskMessage, error) { + pageToken := "" + for { + thread, err := client.GetThread(ctx, connect.NewRequest(&deixicv1.GetThreadRequest{ + Scope: scope, + ThreadId: channelID, + Limit: 50, + PageToken: pageToken, + })) + if err != nil { + return nil, err + } + for _, message := range thread.Msg.GetMessages() { + if message.GetId() == messageID { + return message, nil + } + } + pageToken = thread.Msg.GetNextPageToken() + if pageToken == "" { + return nil, fmt.Errorf("message %s not found", messageID) + } + } +} + +func describe(err error) string { + var connectErr *connect.Error + if errors.As(err, &connectErr) { + return fmt.Sprintf("%s: %s (request %s)", + connectErr.Code(), connectErr.Message(), connectErr.Meta().Get("X-Request-ID")) + } + return err.Error() +} + +func mustEnv(name string) string { + value := os.Getenv(name) + if value == "" { + log.Fatalf("set %s", name) + } + return value +} +``` + +Run it: + +```sh +go run . +``` + +What the program does: + +- **Authentication and scope.** `authInterceptor` sends the API key as a + bearer token and the organization and workspace in the `X-Organization-ID` + and `X-Workspace-ID` headers. Every request message also carries the same + `Scope`, as the TypeScript and Python SDKs do. Set both from the same values. +- **Submission.** `SubmitTask` returns once Deixic accepts the request. The + `AcceptedTurn` identifies the work to follow. If the call fails with a + network error, you cannot tell whether Deixic accepted it: retry with the + same idempotency key and body, never a new key. +- **Waiting.** `waitForTurn` polls `GetThread` until the turn is completed, + failed, interrupted, or waiting for a decision. A `responded` turn has a + preliminary answer and is still running, so the program keeps polling. + Cancelling the context stops polling only; the task keeps running in Deixic. +- **The answer.** A completed turn names its final message in + `AssistantMessageId`. `findMessage` pages through the thread to read it. A + completed answer does not prove every external action succeeded; read each + receipt with `GetReceipt` and check its state before acting on it. + +To stream progress instead of polling, call `WatchEvents` with the thread's +`replay_cursor` and save each event's `cursor`. If a page sets +`reset_required`, replace your local copy of the thread with the snapshot on +that page before you continue. + +## 3. Answer an approval request + +A waiting turn needs a decision from a person who is allowed to make it. Find +the turn's pending request in its events, then respond. Add this function to +`main.go` and call it from the `TURN_STATE_WAITING` case once your application +has the decision: + +```go +// approve answers the turn's pending approval request after an authorized +// person decides. +func approve( + ctx context.Context, + client deixicpublicv1connect.DeixicPublicServiceClient, + scope *deixicv1.Scope, + turn *deixicv1.TaskTurn, + approved bool, +) error { + var request *deixicv1.TaskEvent + cursor := turn.GetFirstCursor() - 1 + for { + page, err := client.ListEvents(ctx, connect.NewRequest(&deixicv1.ListEventsRequest{ + Scope: scope, + ThreadId: channelID, + AfterCursor: cursor, + Limit: 100, + })) + if err != nil { + return err + } + for _, event := range page.Msg.GetEvents() { + if event.GetTurnId() == turn.GetTurnId() && + event.GetRequestKind() == deixicv1.RequestKind_REQUEST_KIND_APPROVAL { + request = event // Keep the latest approval request for this turn. + } + } + cursor = page.Msg.GetNextCursor() + if !page.Msg.GetHasMore() { + break + } + } + if request == nil { + return errors.New("no approval request is visible for this turn") + } + + action := deixicv1.ResponseAction_RESPONSE_ACTION_DENY + if approved { + action = deixicv1.ResponseAction_RESPONSE_ACTION_APPROVE + } + _, err := client.RespondToRequest(ctx, connect.NewRequest(&deixicv1.RespondToRequestRequest{ + Scope: scope, + ThreadId: channelID, + TurnId: turn.GetTurnId(), + RequestId: request.GetRequestId(), + CallId: request.GetCallId(), + RequestKind: request.GetRequestKind(), + Action: action, + IdempotencyKey: "decision-" + request.GetRequestId(), + })) + return err +} +``` + +Deixic checks that the caller may make the decision. To stop a running task +instead, call `InterruptTask` with the turn ID, a new idempotency key, and a +reason. + +## Handle errors + +Failed calls return a `*connect.Error`. Its `Code()` gives the category, and +the `X-Request-ID` response header, available from `Meta()`, identifies the +request when you contact support. The `describe` function in the program above +prints both. + +## Next steps + +- [Go client reference](https://github.com/dx-corp/deixic-go#readme): module layout and + source of the generated code. +- [TypeScript](getting-started-typescript.md) and + [Python](getting-started-python.md) guides: the SDK task helpers show the + full observation and restart-recovery behavior that a Go program can follow. +- [Deixic SDK contract](../deixic-sdk.md): supported operations and safety + rules. diff --git a/docs/sdk/getting-started-python.md b/docs/sdk/getting-started-python.md new file mode 100644 index 0000000..7fa4679 --- /dev/null +++ b/docs/sdk/getting-started-python.md @@ -0,0 +1,260 @@ +# Getting started with the Deixic Python SDK + +This guide builds a small Python script that checks access to a Deixic +channel, submits a task, waits for the answer, and handles an approval request. +Read the [guide index](README.md) first for the credentials and concepts every +SDK shares. + +## Requirements + +- Python 3.10 or later +- The `DEIXIC_*` environment variables described in the + [guide index](README.md#before-you-start) + +Use API keys only from trusted server-side code. + +## 1. Install the package + +```sh +python3 -m venv .venv +. .venv/bin/activate +pip install deixic-sdk +``` + +## 2. Create a client + +Create `client.py`: + +```python +import os + +from deixic import Deixic + +deixic = Deixic( + api_key=os.environ["DEIXIC_API_KEY"], + organization_id=os.environ["DEIXIC_ORGANIZATION_ID"], + workspace_id=os.environ["DEIXIC_WORKSPACE_ID"], + base_url=os.environ.get("DEIXIC_BASE_URL", "https://app.deixic.com"), +) +``` + +The organization and workspace are fixed for this client. Every request sends +them in the request body and in the `X-Organization-ID` and `X-Workspace-ID` +headers. + +## 3. Check setup + +Before you submit work, confirm that the key can read the channel and that the +workspace has a model available: + +```python +import sys + +from client import deixic + +setup = deixic.tasks.check_setup(channel_id="company") +if setup.status != "accessible": + request_id = setup.error.request_id if setup.error else None + print(setup.status, setup.next_action, request_id, file=sys.stderr) + sys.exit(1) +print("Default model:", setup.default_model.model if setup.default_model else "none") +``` + +`check_setup` makes one read request. It does not prove write access; +submission checks write authorization. + +## 4. Submit a task and wait for the answer + +Create `quickstart.py`: + +```python +import sys + +from client import deixic + +CHANNEL_ID = "company" + +task = deixic.tasks.start( + channel_id=CHANNEL_ID, + body="Summarize the open changes in this workspace.", + # One key per business request. Reuse it only to retry this same request. + idempotency_key="quickstart-2026-09-25-001", +) + +result = task.wait( + timeout=120, + on_event=lambda event: print("event", event.kind, event.text, file=sys.stderr), +) + +if result.status == "completed": + print(result.body) + for receipt in result.receipts: + print("receipt", receipt.id, receipt.title, receipt.state) +elif result.status == "waiting": + print("Needs a decision:", result.reason, result.event.request_id if result.event else "") +else: + # failed, interrupted, unfinished, or unacknowledged. + print(result.status, result.reason) +``` + +Run it: + +```sh +python quickstart.py +``` + +`tasks.start()` returns once Deixic accepts the request. `task.wait()` then +follows the turn's events until it completes, fails, is interrupted, or needs +attention. If `timeout` (in seconds) elapses first, the result is `unfinished` +and the work keeps running in Deixic. Stopping observation never cancels the +task. + +A completed answer does not prove that every external action succeeded. Check +each receipt's `state` (for example `protocol.RECEIPT_STATE_SUCCEEDED`) before +you act on it. + +To validate a structured answer, pass your own parser: +`result.parse(json.loads)`. Parser errors propagate. + +## 5. Answer an approval request + +When a task needs an approval, `wait()` returns `status == "waiting"` with the +pending request in `result.event`. Show it to a person who is allowed to +decide, then send that decision: + +```python +from deixic import protocol as pb + +if ( + result.status == "waiting" + and result.event is not None + and result.event.request_kind == pb.REQUEST_KIND_APPROVAL +): + request = result.event + approved = ask_operator(request) # Your application's decision. + + deixic.controls.respond( + channel_id=CHANNEL_ID, + turn_id=result.turn_id, + response=pb.RespondToRequestRequest( + request_id=request.request_id, + call_id=request.call_id, + request_kind=request.request_kind, + action=pb.RESPONSE_ACTION_APPROVE if approved else pb.RESPONSE_ACTION_DENY, + ), + idempotency_key=f"decision-{request.request_id}", + ) + + after = task.wait(timeout=120) + print(after.status) +``` + +Deixic checks that the caller may make the decision. To stop a running task +instead, call +`deixic.controls.interrupt(channel_id=..., turn_id=..., idempotency_key=..., reason=...)`. + +## 6. Survive a restart + +A worker can crash after it submits a task but before it reads the result. +Use `tasks.prepare()` with an `on_checkpoint` callback to save progress, and +`tasks.resume()` in a later process: + +```python +import json +import os +from pathlib import Path + +from client import deixic + +STATE = Path("task.json") + + +def save(checkpoint): + STATE.write_text(json.dumps(checkpoint)) + os.chmod(STATE, 0o600) + + +# First process: save before sending, then submit. +task = deixic.tasks.prepare( + channel_id="company", + body="Summarize the open changes in this workspace.", + idempotency_key="quickstart-2026-09-25-002", + on_checkpoint=save, +) +task.submit() + +# Later process: resume observation. This never submits work. +resumed = deixic.tasks.resume(json.loads(STATE.read_text()), on_checkpoint=save) +result = resumed.wait(timeout=120) +``` + +If the result is `unacknowledged`, the submit response was lost. Call +`resumed.replay()` to resend the original request with its original key. Do +not start again with a new key. Checkpoints contain the task body but no +credential; store them as customer data. + +The installed package includes a complete version of this flow: + +```sh +python -m deixic.examples.task_result start task.json \ + --channel company --body 'Summarize the open changes in this workspace.' +python -m deixic.examples.task_result resume task.json +``` + +## Handle errors + +SDK failures raise `DeixicError`: + +```python +from deixic import DeixicError + +try: + deixic.tasks.start(channel_id="company", body="Hi", idempotency_key="k-1") +except DeixicError as error: + print(error.kind, error.status_code, error.code, error.request_id) + raise +``` + +Include `request_id` when you contact support. Mutations are never retried +automatically after a transport or availability failure; retry with the same +idempotency key only when you mean to resend the same request. + +## Authenticate without a stored key + +CI jobs and cloud workloads can exchange a platform-issued token for a +short-lived Deixic token instead of storing an API key. Pass a +`credential_provider` instead of `api_key`: + +```python +import os + +from deixic import ( + Deixic, + WorkloadFederationCredentialProvider, + github_actions_assertion_source, +) + +identity_url = os.environ["DEIXIC_IDENTITY_URL"] +deixic = Deixic( + credential_provider=WorkloadFederationCredentialProvider( + identity_url=identity_url, + assertion_source=github_actions_assertion_source( + audience=f"{identity_url}/v1/workload-federation/exchange", + ), + ), + organization_id=os.environ["DEIXIC_ORGANIZATION_ID"], + workspace_id=os.environ["DEIXIC_WORKSPACE_ID"], +) +``` + +An organization admin must register the issuer and a matching rule first. See +[Workload federation](https://github.com/dx-corp/deixic-python#workload-federation) +for the setup and the available assertion sources. + +## Next steps + +- [Python SDK reference](https://github.com/dx-corp/deixic-python#readme): task outcomes, + the account-brief example application, reading coding output, and the + lower-level `threads`, `events`, `messages`, and `receipts` methods. +- [Deixic SDK contract](../deixic-sdk.md): supported operations and safety + rules. diff --git a/docs/sdk/getting-started-typescript.md b/docs/sdk/getting-started-typescript.md new file mode 100644 index 0000000..5a4e8ff --- /dev/null +++ b/docs/sdk/getting-started-typescript.md @@ -0,0 +1,252 @@ +# Getting started with the Deixic TypeScript SDK + +This guide builds a small Node.js script that checks access to a Deixic +channel, submits a task, waits for the answer, and handles an approval request. +Read the [guide index](README.md) first for the credentials and concepts every +SDK shares. + +## Requirements + +- Node.js 20 or later +- The `DEIXIC_*` environment variables described in the + [guide index](README.md#before-you-start) + +Run the SDK from trusted server-side code. Do not ship an API key to a browser; +a browser application should call its own authenticated server, or supply an +OAuth credential source as shown in [Use rotating credentials](#use-rotating-credentials). + +## 1. Install the package + +```sh +mkdir deixic-quickstart && cd deixic-quickstart +npm init -y +npm pkg set type=module +npm install @evalops/deixic-sdk +``` + +## 2. Create a client + +Create `client.mjs`: + +```js +import { createDeixicClient } from "@evalops/deixic-sdk"; + +function requiredEnv(name) { + const value = process.env[name]; + if (!value) throw new Error(`Set ${name}`); + return value; +} + +export const deixic = createDeixicClient({ + apiKey: requiredEnv("DEIXIC_API_KEY"), + organizationId: requiredEnv("DEIXIC_ORGANIZATION_ID"), + workspaceId: requiredEnv("DEIXIC_WORKSPACE_ID"), + // Omit baseUrl to use https://app.deixic.com. + baseUrl: process.env.DEIXIC_BASE_URL, +}); +``` + +The organization and workspace are fixed for this client. Every request sends +them in the request body and in the `X-Organization-ID` and `X-Workspace-ID` +headers. + +## 3. Check setup + +Before you submit work, confirm that the key can read the channel and that the +workspace has a model available: + +```js +import { deixic } from "./client.mjs"; + +const setup = await deixic.tasks.checkSetup({ channelId: "company" }); +if (setup.status !== "accessible") { + console.error(setup.status, setup.nextAction, setup.error?.requestId); + process.exit(1); +} +console.log("Default model:", setup.defaultModel?.model ?? "none"); +``` + +`checkSetup` makes one read request. It does not prove write access; +submission checks write authorization. + +## 4. Submit a task and wait for the answer + +Create `quickstart.mjs`: + +```js +import { deixic } from "./client.mjs"; + +const channelId = "company"; + +const task = await deixic.tasks.start({ + channelId, + body: "Summarize the open changes in this workspace.", + // One key per business request. Reuse it only to retry this same request. + idempotencyKey: "quickstart-2026-09-25-001", +}); + +const result = await task.wait({ + timeoutMs: 120_000, + onEvent: (event) => console.error("event", event.kind, event.text), +}); + +switch (result.status) { + case "completed": + console.log(result.body); + for (const receipt of result.receipts) { + console.log("receipt", receipt.id, receipt.title, receipt.state); + } + break; + case "waiting": + console.log("Needs a decision:", result.reason, result.event?.requestId); + break; + default: + // failed, interrupted, unfinished, or unacknowledged. + console.log(result.status, result.reason ?? ""); +} +``` + +Run it: + +```sh +node quickstart.mjs +``` + +`tasks.start()` returns once Deixic accepts the request. `task.wait()` then +follows the turn's events until it completes, fails, is interrupted, or needs +attention. If `timeoutMs` elapses first, the result is `unfinished` and the +work keeps running in Deixic. Stopping observation never cancels the task. + +A completed answer does not prove that every external action succeeded. Check +each receipt's `state` (for example `ReceiptLifecycleState.SUCCEEDED`) before +you act on it. + +## 5. Answer an approval request + +When a task needs an approval, `wait()` returns `status: "waiting"` with the +pending request in `result.event`. Show it to a person who is allowed to +decide, then send that decision: + +```js +import { + OperatingThreadRequestType, + OperatingThreadResponseAction, +} from "@evalops/deixic-sdk"; + +if ( + result.status === "waiting" && + result.event?.requestKind === OperatingThreadRequestType.APPROVAL +) { + const request = result.event; + const approved = await askOperator(request); // Your application's decision. + + await deixic.controls.respond({ + channelId, + turnId: result.turnId, + response: { + requestId: request.requestId, + callId: request.callId, + requestKind: request.requestKind, + action: approved + ? OperatingThreadResponseAction.APPROVE + : OperatingThreadResponseAction.DENY, + }, + idempotencyKey: `decision-${request.requestId}`, + }); + + const after = await task.wait({ timeoutMs: 120_000 }); + console.log(after.status); +} +``` + +Deixic checks that the caller may make the decision. To stop a running task +instead, call +`deixic.controls.interrupt({ channelId, turnId, idempotencyKey, reason })`. + +## 6. Survive a restart + +A worker can crash after it submits a task but before it reads the result. +Use `tasks.prepare()` with an `onCheckpoint` callback to save progress, and +`tasks.resume()` in a later process: + +```js +import { readFile, writeFile } from "node:fs/promises"; +import { deixic } from "./client.mjs"; + +const save = (checkpoint) => + writeFile("task.json", JSON.stringify(checkpoint), { mode: 0o600 }); + +// First process: save before sending, then submit. +const task = await deixic.tasks.prepare({ + channelId: "company", + body: "Summarize the open changes in this workspace.", + idempotencyKey: "quickstart-2026-09-25-002", + onCheckpoint: save, +}); +await task.submit(); + +// Later process: resume observation. This never submits work. +const resumed = deixic.tasks.resume( + JSON.parse(await readFile("task.json", "utf8")), + { onCheckpoint: save }, +); +const result = await resumed.wait({ timeoutMs: 120_000 }); +``` + +If the result is `unacknowledged`, the submit response was lost. Call +`await resumed.replay()` to resend the original request with its original key. +Do not start again with a new key. Checkpoints contain the task body but no +credential; store them as customer data. + +## Handle errors + +SDK failures are `DeixicError` instances: + +```js +import { DeixicError } from "@evalops/deixic-sdk"; + +try { + await deixic.tasks.start({ channelId: "company", body: "Hi", idempotencyKey: "k-1" }); +} catch (error) { + if (error instanceof DeixicError) { + console.error(error.kind, error.status, error.code, error.requestId); + } + throw error; +} +``` + +Include `requestId` when you contact support. Mutations are never retried +automatically after a transport or availability failure; retry with the same +idempotency key only when you mean to resend the same request. + +## Use rotating credentials + +For OAuth or short-lived workload tokens, pass `auth` instead of `apiKey`: + +```js +const deixic = createDeixicClient({ + organizationId, + workspaceId, + auth: { + getCredential: () => ({ + accessToken, + subject, + organizationId, + workspaceId, + scopes: ["console:read", "console:write"], + }), + refreshCredential: refreshAccessToken, + }, +}); +``` + +After an authentication challenge, the SDK retries once with the refreshed +credential, and only if it keeps the same subject, tenant, and scopes. + +## Next steps + +- [TypeScript SDK reference](https://github.com/dx-corp/deixic-node#readme): task + outcomes, the account-brief example application, thread recovery, and the + lower-level `threads`, `events`, `messages`, and `receipts` methods. +- [Deixic SDK contract](../deixic-sdk.md): supported operations and safety + rules.