> For the complete documentation index, see [llms.txt](https://docs.duku.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.duku.ai/integrations/triggering-explorations-from-any-ci.md).

# Triggering explorations from any CI

## Triggering explorations from any CI

Duku's GitHub Actions are thin wrappers around our Platform API. If your pipelines run somewhere else - CircleCI, Jenkins, GitLab CI, Buildkite - you can make the same API calls directly with `curl`. This page walks through the complete flow, with CircleCI as the worked example.

> **Using GitHub Actions?** Use the Duku `preview` or `environment` actions instead - they implement everything on this page for you.

What you will set up:

* Exchange your Duku API key for a short-lived access token.
* **Preview flow** - register a per-PR build and start an exploration against its preview deployment.
* **Environment flow** - start an exploration and test-case run against a long-lived environment target.
* Get results posted back to your GitHub pull requests via the Duku AI GitHub App.
* Optionally, block your pipeline until the exploration finishes.

> **Status.** This API surface is the same one our published actions are built on. Like the actions, it is pre-release - operation and field names are stable in practice but may evolve before 1.0.

### Endpoints

| What                   | URL                                                              |
| ---------------------- | ---------------------------------------------------------------- |
| Token endpoint         | `https://auth.duku.ai/realms/duku/protocol/openid-connect/token` |
| Platform API (GraphQL) | `https://platform.duku.ai/graphql`                               |

All Platform API operations are GraphQL: `POST` a JSON body of the form `{"query": "...", "variables": {...}}` with an `Authorization: Bearer <token>` header.

### Prerequisites

* A Duku API key. Generate one in **Viewport → Settings → API Keys**. The key is shown once - store it as a secret in your CI. In CircleCI, put it in a context as `DUKU_API_KEY`.
* **Preview flow:** your product ID (Viewport → Products).
* **Environment flow:** a pre-created environment target ID (created in Viewport).
* Your exploration must be set up in Viewport first. If a target has no exploration configured, `startExploration` returns an error describing what is missing - complete the setup in Viewport and retry.
* `curl` and `jq` in your job image. CircleCI's `cimg/base` image includes both.
* For PR comments: the Duku AI GitHub App installed on your repository (next section).

> **Tip.** You can list your products over the API with `query { subjects { id name } }` - products are called "subjects" in the API schema.

### Install the Duku AI GitHub App

When you pass a repository and pull-request number with an exploration, Duku posts a sticky comment on that PR - first "exploration in progress", then updated in place with the results once the exploration finishes. Those comments are posted by the Duku AI GitHub App, so you never need to handle a GitHub token in your CI job.

1. Open [github.com/apps/duku-ai](https://github.com/apps/duku-ai).
2. Click **Install** (or **Configure** if your organisation already has it) and choose the GitHub organisation that owns your repositories. If you are not an organisation owner, GitHub forwards the request to one for approval.
3. Choose **Only select repositories** and pick the repositories whose pull requests should receive Duku comments (or **All repositories**).
4. Accept the requested permissions - the app only needs to read and write pull-request comments.

That's it. There is nothing to configure on the Duku side: the platform discovers the installation automatically from the repository you name in the API call.

If the app is not installed, explorations still run normally - you just won't get PR comments. Results remain available in Viewport.

### Step 1 - Exchange your API key for an access token

See [API Keys](/integrations/api-keys.md) for how keys work and how to exchange one for an access token. In short: the key is a base64-encoded `clientId:clientSecret` pair; decode it, split on the first `:`, and use the standard OAuth2 client-credentials grant to get a short-lived access token.

Save the following helpers to `scripts/duku-lib.sh` in your repository - the rest of this page builds on them:

```bash
#!/usr/bin/env bash
# scripts/duku-lib.sh - helpers for calling the Duku Platform API.
# Requires: bash, curl, jq. Expects DUKU_API_KEY in the environment.

DUKU_API_URL=${DUKU_API_URL:-https://platform.duku.ai/graphql}
DUKU_AUTH_URL=${DUKU_AUTH_URL:-https://auth.duku.ai}

# Exchange the API key for a short-lived access token.
duku_token() {
  local decoded client_id client_secret
  decoded=$(printf '%s' "$DUKU_API_KEY" | base64 -d) || {
    echo 'DUKU_API_KEY is not valid base64' >&2
    return 1
  }
  client_id=${decoded%%:*}
  client_secret=${decoded#*:}
  curl -sfS -X POST "$DUKU_AUTH_URL/realms/duku/protocol/openid-connect/token" \
    -H 'Content-Type: application/x-www-form-urlencoded' \
    --data-urlencode grant_type=client_credentials \
    --data-urlencode "client_id=$client_id" \
    --data-urlencode "client_secret=$client_secret" |
    jq -r .access_token
}

# duku_gql <query> <variables-json>
# Runs a GraphQL operation. Prints the JSON response on success; prints the
# API's error messages and returns non-zero if the response contains errors.
duku_gql() {
  local query=$1 variables=${2:-'{}'} body response
  body=$(jq -n --arg q "$query" --argjson v "$variables" '{query: $q, variables: $v}')
  response=$(curl -sfS "$DUKU_API_URL" \
    -H "Authorization: Bearer $(duku_token)" \
    -H 'Content-Type: application/json' \
    -d "$body") || return 1
  if jq -e 'has("errors")' <<<"$response" >/dev/null; then
    echo 'Duku API error:' >&2
    jq -r '.errors[].message' <<<"$response" >&2
    return 1
  fi
  printf '%s\n' "$response"
}
```

> **Important.** Access tokens are short-lived (about 5 minutes). Don't fetch one token at the top of a long job and reuse it - `duku_gql` above fetches a fresh token per call, which is the simplest safe pattern.

> **Important.** GraphQL errors come back with HTTP status 200, so a bare `curl -f` will not catch them. Always check the response for an `errors` array, as `duku_gql` does.

A word on hygiene: treat the API key and access tokens as secrets. Avoid `set -x` in steps that call these helpers, and never echo the token.

{% hint style="info" %}
Avoid sending passwords in the request body. Instead of passing raw login credentials inline, create a credential set once in Viewport and pass its ID or name as `credentialSetId` on `startExploration` or `runAllTestCases`. Duku decrypts it server-side, so the secret never travels through your CI logs or the GraphQL request.
{% endhint %}

### Adapting the scripts to your CI

The helper scripts on this page use CircleCI environment variable names (`CIRCLE_*`) as examples. If you run a different CI, substitute your provider's equivalents - the GraphQL calls and the rest of each script stay exactly the same.

| What it is                  | CircleCI (used in the scripts)                              | GitHub Actions                                                      | GitLab CI               | Jenkins                                 |
| --------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- | ----------------------- | --------------------------------------- |
| Repository (`owner/repo`)   | `$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME`         | `$GITHUB_REPOSITORY`                                                | `$CI_PROJECT_PATH`      | derive from `$GIT_URL`, or set manually |
| Pull / merge request number | parsed from `$CIRCLE_PULL_REQUEST` (or `$CIRCLE_PR_NUMBER`) | `github.event.pull_request.number`                                  | `$CI_MERGE_REQUEST_IID` | `$CHANGE_ID`                            |
| Commit SHA                  | `$CIRCLE_SHA1`                                              | `$GITHUB_SHA`                                                       | `$CI_COMMIT_SHA`        | `$GIT_COMMIT`                           |
| Branch name                 | `$CIRCLE_BRANCH`                                            | `$GITHUB_HEAD_REF` (PRs) / `$GITHUB_REF_NAME`                       | `$CI_COMMIT_REF_NAME`   | `$BRANCH_NAME`                          |
| Build number                | `$CIRCLE_BUILD_NUM`                                         | `$GITHUB_RUN_NUMBER`                                                | `$CI_PIPELINE_IID`      | `$BUILD_NUMBER`                         |
| Build / job URL             | `$CIRCLE_BUILD_URL`                                         | `$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID` | `$CI_JOB_URL`           | `$BUILD_URL`                            |

### Flow A - preview deployments

Two API calls per pipeline, run after your preview deploy step:

1. `upsertSimulationTarget` registers the build as a target under your product. The `buildKey` deduplicates reruns - every pipeline on the same PR updates the same target instead of creating a new one.
2. `startExploration` starts the exploration against the preview URL. Passing `githubRepository`, `githubPrNumber`, and `serverManagedComment: true` makes Duku post and maintain the PR comment for you.

{% hint style="warning" %}
**`triggerSource: "CI"` is required in this flow.** The per-run `url` is treated as an override, and Duku only honours it for CI callers. If you omit `triggerSource`, your API key is treated as a generic key - the `url` you send is ignored and the exploration runs against the product's configured base URL (or fails if the product has no base URL set). The script below includes it.
{% endhint %}

Unlike the GitHub Action - which discovers the preview URL from the GitHub Deployments, Checks, and Statuses APIs - your CI job supplies the preview URL directly. Your deploy step already knows it: `vercel deploy` prints it, `netlify deploy --json` returns it as `deploy_url`, or you may construct it from the PR number.

```bash
#!/usr/bin/env bash
# scripts/duku-preview.sh - register a PR build with Duku and start an
# exploration against its preview deployment.
#
# Required environment:
#   DUKU_API_KEY     Duku API key (Viewport -> Settings -> API Keys)
#   DUKU_PRODUCT_ID  Duku product ID (Viewport -> Products)
#   PREVIEW_URL      URL of the deployed preview for this PR
set -euo pipefail
source "$(dirname "$0")/duku-lib.sh"

: "${DUKU_API_KEY:?}" "${DUKU_PRODUCT_ID:?}" "${PREVIEW_URL:?}"

REPO="${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}"
PR_NUMBER=${CIRCLE_PR_NUMBER:-}
if [ -z "$PR_NUMBER" ] && [ -n "${CIRCLE_PULL_REQUEST:-}" ]; then
  PR_NUMBER=${CIRCLE_PULL_REQUEST##*/}
fi
: "${PR_NUMBER:?No pull request associated with this pipeline}"
SHORT_SHA=${CIRCLE_SHA1:0:7}

# 1. Register (or refresh) the build as a target under your product.
UPSERT='mutation UpsertSimulationTarget($input: UpsertSimulationTargetInput!) {
  upsertSimulationTarget(input: $input) { id name version }
}'
VARS=$(jq -n \
  --arg subjectId "$DUKU_PRODUCT_ID" \
  --arg buildKey "github:repo=${REPO}:pr=${PR_NUMBER}" \
  --arg name "PR #${PR_NUMBER} (${SHORT_SHA})" \
  --arg description "Build from ${CIRCLE_BRANCH:-unknown} (${SHORT_SHA})" \
  --arg version "$SHORT_SHA" \
  --argjson buildNumber "${CIRCLE_BUILD_NUM:-null}" \
  --arg buildUrl "${CIRCLE_BUILD_URL:-}" \
  '{input: {subjectId: $subjectId, buildKey: $buildKey, name: $name,
           description: $description, version: $version, environment: "build",
           buildNumber: $buildNumber, buildUrl: $buildUrl}}')
TARGET_ID=$(duku_gql "$UPSERT" "$VARS" | jq -r '.data.upsertSimulationTarget.id')
echo "Registered build as target ${TARGET_ID}"

# 2. Start the exploration. serverManagedComment makes Duku post and update
#    the sticky PR comment - no GitHub token needed in this job.
#    triggerSource: "CI" authorises the per-run url override (see note above).
START='mutation StartExploration($input: StartExplorationInput!) {
  startExploration(input: $input) { id status }
}'
VARS=$(jq -n \
  --arg targetId "$TARGET_ID" \
  --arg url "$PREVIEW_URL" \
  --arg repo "$REPO" \
  --argjson pr "$PR_NUMBER" \
  '{input: {targetId: $targetId, url: $url, githubRepository: $repo,
           githubPrNumber: $pr, serverManagedComment: true, triggerSource: "CI"}}')
RUN_ID=$(duku_gql "$START" "$VARS" | jq -r '.data.startExploration.id')
echo "Exploration started: run ${RUN_ID}"
echo "$RUN_ID" > .duku-run-id   # for the optional gate step
```

The exploration runs asynchronously - the script exits in seconds and results appear in Viewport and on the PR comment when the run finishes (typically 5 to 40 minutes).

If one pull request triggers explorations for several products, each product gets its own PR comment.

> **Vercel Deployment Protection.** If your previews are protected, pass the bypass secret when registering the build. Add `--arg bypass "$VERCEL_AUTOMATION_BYPASS_SECRET"` to the first `jq` call and `metadata: {vercelAutomationBypassSecret: $bypass}` to its input object.

> **Not on a PR?** For pushes without a pull request, use `buildKey: "github:repo=${REPO}:sha=${CIRCLE_SHA1}"` and omit `githubRepository`, `githubPrNumber`, and `serverManagedComment` - there is no PR to comment on. Keep `triggerSource: "CI"` so the preview `url` is still honoured.

Keep the `github:repo=...` `buildKey` convention shown above even though you're not on GitHub Actions: it matches what the Duku GitHub Actions generate, so if you ever run both, they deduplicate onto the same targets.

For the full list of input fields on `upsertSimulationTarget` and `startExploration`, see the [GraphQL API reference](/integrations/graphql-api.md).

### Flow B - environment deployments

For a staging environment, prod canary, or any pre-created environment target: skip target registration and start the run directly, then trigger the target's test cases.

```bash
#!/usr/bin/env bash
# scripts/duku-environment.sh - start an exploration and a test-case run
# against a pre-created environment target.
#
# Required environment:
#   DUKU_API_KEY    Duku API key (Viewport -> Settings -> API Keys)
#   DUKU_TARGET_ID  Environment target ID (created in Viewport)
set -euo pipefail
source "$(dirname "$0")/duku-lib.sh"

: "${DUKU_API_KEY:?}" "${DUKU_TARGET_ID:?}"

# 1. Start the exploration. Omitting url uses the URL configured on the target.
START='mutation StartExploration($input: StartExplorationInput!) {
  startExploration(input: $input) { id status }
}'
VARS=$(jq -n --arg targetId "$DUKU_TARGET_ID" '{input: {targetId: $targetId, triggerSource: "CI"}}')
RUN_ID=$(duku_gql "$START" "$VARS" | jq -r '.data.startExploration.id')
echo "Exploration started: run ${RUN_ID}"
echo "$RUN_ID" > .duku-run-id

# 2. Run every test case configured on the target.
TESTS='mutation RunAllTestCases($input: RunAllTestCasesInput!) {
  runAllTestCases(input: $input) { success intentBatchId }
}'
VARS=$(jq -n --arg targetId "$DUKU_TARGET_ID" '{input: {targetId: $targetId}}')
TEST_RUN_ID=$(duku_gql "$TESTS" "$VARS" | jq -r '.data.runAllTestCases.intentBatchId')
if [ -n "$TEST_RUN_ID" ]; then
  echo "Test-case run started: ${TEST_RUN_ID}"
else
  echo "Target has no test cases configured - test run skipped"
fi
```

{% hint style="info" %}
`triggerSource: "CI"` is optional here - Flow B runs against the target's configured URL, so there is no override to authorise. It is still worth sending so the run is labelled as CI-triggered in your dashboards.
{% endhint %}

* **PR-gate mode:** to run this against a fixed environment as a pull-request check and get the PR comment, add `githubRepository`, `githubPrNumber`, and `serverManagedComment: true` to the `startExploration` input, exactly as in the preview flow.
* `runAllTestCases` plans the test runs synchronously and can take up to \~90 seconds to respond; the runs themselves then execute asynchronously.

### Gate your pipeline on the result

Both flows are async by design: they kick off the run and exit, and results arrive in Viewport and the PR comment.

{% hint style="success" %}
**Recommended: gate on the GitHub check run, not by polling.** When you start the exploration with `serverManagedComment: true` and PR context - as Flow A does, and Flow B in PR-gate mode - Duku posts a **GitHub check run** you can require in branch protection. The merge is blocked or cleared automatically and your CI job can exit immediately, with no executor held open. See [GitHub check runs](/integrations/github-check-runs.md). Reach for polling only when you can't use branch protection - a non-GitHub host, or a gate inside the job itself.
{% endhint %}

To make the CI job itself pass or fail with the exploration - for a non-GitHub CI, or a step-level gate - poll the batch status instead. Add to `scripts/duku-lib.sh`:

```bash
# duku_wait <run-id> [timeout-seconds]
# Polls until the run reaches a terminal status. Returns 0 on completed,
# 1 on failed or timeout.
duku_wait() {
  local run_id=$1 timeout=${2:-2700} deadline status
  local query='query GetBatch($id: ID!) { batch(id: $id) { id status } }'
  deadline=$(( $(date +%s) + timeout ))
  while :; do
    status=$(duku_gql "$query" "$(jq -n --arg id "$run_id" '{id: $id}')" |
      jq -r '.data.batch.status')
    echo "$(date -u '+%H:%M:%S') run ${run_id}: ${status}"
    case $status in
      completed) return 0 ;;
      failed)    echo 'Exploration failed - see the PR comment or Viewport' >&2; return 1 ;;
    esac
    if [ "$(date +%s)" -ge "$deadline" ]; then
      echo "Timed out waiting for run ${run_id}" >&2
      return 1
    fi
    sleep 30
  done
}
```

Usage after either flow script:

```bash
source scripts/duku-lib.sh
duku_wait "$(cat .duku-run-id)"
```

* `pending` and `running` are in-flight; `completed` and `failed` are terminal.
* Explorations typically take 5 to 40 minutes - set the step timeout accordingly (the loop prints a line every 30 seconds, so CircleCI's no-output timeout won't trip).
* Polling keeps a CircleCI executor occupied for the whole run. If you only need the results, skip the gate and let the PR comment (or the check run above) deliver them.

### Putting it together in CircleCI

Store `DUKU_API_KEY` (and `DUKU_PRODUCT_ID` or `DUKU_TARGET_ID`) in a context named `duku` under **Organization Settings → Contexts**, then:

```yaml
version: 2.1

jobs:
  duku-preview:
    docker:
      - image: cimg/base:current   # includes curl and jq
    steps:
      - checkout
      # ... your existing preview deploy; capture the URL it produces ...
      - run:
          name: Deploy preview
          command: ./scripts/deploy-preview.sh > preview-url.txt
      - run:
          name: Start Duku exploration
          command: |
            export PREVIEW_URL=$(cat preview-url.txt)
            ./scripts/duku-preview.sh
      # Optional gate - remove this step for fire-and-forget behaviour
      - run:
          name: Wait for Duku results
          no_output_timeout: 20m
          command: |
            source scripts/duku-lib.sh
            duku_wait "$(cat .duku-run-id)"

workflows:
  preview:
    jobs:
      - duku-preview:
          context: duku
```

> **Note.** CircleCI only populates `CIRCLE_PULL_REQUEST` / `CIRCLE_PR_NUMBER` when the pipeline is associated with a pull request. If those are empty on your PR pipelines, check **Project Settings → Advanced → Only build pull requests**, or pass the PR number into the script yourself.

### Troubleshooting

**Token request fails with HTTP 401 (`invalid_client`).** The API key is malformed, revoked, or expired. Check that the secret contains the full base64 string from Viewport with no line breaks, or generate a new key in Viewport → Settings → API Keys.

**`Authentication required` in a GraphQL error.** The access token expired mid-job - tokens last about 5 minutes. Fetch a fresh token per call (the `duku_gql` helper does this) rather than reusing one.

**Exploration setup error.** The product or target has not been set up for exploration yet. Complete its setup in Viewport, then retry.

**`Not found` when referencing a target or product.** The ID is wrong, or it belongs to a different organisation - API keys only see resources in the organisation they were created in.

**The preview `url` is ignored / the run uses the wrong URL.** For an API-key caller, the per-run `url` is only honoured when you send `triggerSource: "CI"`. Without it the run falls back to the product's configured base URL. Add `triggerSource: "CI"` to the `startExploration` input, as shown in Flow A.

**The PR comment never appears.** Verify the Duku AI GitHub App is installed on the repository, that `githubRepository` is exactly `owner/repo`, and that `githubPrNumber` is an integer. The results comment posts only when the run finishes - check its status in Viewport if you're unsure whether it's still running.

**Duku's agents can't reach your preview.** If your site sits behind a firewall, WAF, or bot protection, see [Firewall & WAF Allowlisting](/integrations/firewall-and-waf-allowlisting.md). For protected Vercel previews, use the Deployment Protection bypass described above.

Need help? If anything on this page doesn't work for your setup, or you're integrating a CI provider with quirks we haven't covered, contact your Duku account team.<br>
