> 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/graphql-api.md).

# GraphQL API

Query Duku data and trigger runs programmatically.

The Duku GraphQL API gives you programmatic access to products, targets, and runs.

### Next steps

For a complete CI walkthrough, see Triggering explorations from any CI. It covers token exchange, preview and environment flows, and pipeline gating.

### Endpoint

```
POST https://platform.duku.ai/graphql
```

### Authentication

Every request requires a Bearer token in the `Authorization` header. Use the OAuth2 client credentials flow described in API Keys.

### Request format

All requests use an HTTP `POST` with a JSON body:

```json
{
  "query": "query { subjects { id name } }",
  "variables": {}
}
```

Example request:

```bash
curl -X POST https://platform.duku.ai/graphql \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{"query": "{ subjects { id name } }"}'
```

{% hint style="info" %}
Example IDs such as `sms_...` (products), `smt_...` (targets), and `key_...` are placeholders.
{% endhint %}

### Queries

* [Issues API](/integrations/issues-api.md)

#### List products

```graphql
query {
  subjects {
    id
    name
  }
}
```

#### List targets

```graphql
query GetTargets($subjectId: ID) {
  targets(subjectId: $subjectId) {
    id
    name
  }
}
```

#### List runs for a target

Use this query to retrieve runs for a target. Arguments are `limit` (default `50`), `offset` (default `0`), `status`, `approach`, `search`, `sortBy`, and `sortDirection`. The response is `PaginatedRuns { runs, hasMore, totalCount }`.

```graphql
query GetRuns($targetId: ID) {
  runs(targetId: $targetId) {
    hasMore
    totalCount
    runs {
      id
      name
      status
      exceptionCount
      startTime
      endTime
      target {
        id
        name
      }
    }
  }
}
```

`batch(id) { runs }` returns at most the 200 most recent runs - use the top-level `runs` query when you need more.

#### Get run details

```graphql
query GetRun($id: ID!) {
  run(id: $id) {
    id
    name
    status
    exceptionCount
    startTime
    endTime
    target {
      id
      name
    }
  }
}
```

Run statuses are lowercase: `pending`, `running`, `completed`, or `failed`.

### Mutations

#### Create or update a target

Use `upsertSimulationTarget` to create a target the first time, then update it on later runs.

`buildKey` is the stable identity key for the target. Duku deduplicates by `subjectId` + `buildKey`. If you call the mutation again with the same pair, Duku updates the existing target instead of creating a duplicate.

For per-PR targets, use a stable key such as `github:repo=owner/repo:pr=123`.

Required input fields:

* `subjectId`: your Product ID
* `buildKey`: the stable identity key
* `name`: the target name shown in Viewport

Optional input fields:

* `description`
* `version`
* `environment`
* `buildNumber`
* `metadata`

A target inherits its URL from its product, so there is no target URL to set here. To explore a specific URL, pass `url` on `startExploration` (see below).

* `buildUrl` is deprecated and ignored. A target inherits its URL from its product; pass a per-run `url` on `startExploration` instead. Use `metadata` for build context - `metadata.ciRunUrl` renders as a **View CI run** link on the target.

```graphql
mutation Upsert($input: UpsertSimulationTargetInput!) {
  upsertSimulationTarget(input: $input) {
    id
    name
    version
  }
}
```

Example variables:

```json
{
  "input": {
    "subjectId": "sms_product_id",
    "buildKey": "github:repo=owner/repo:pr=123",
    "name": "PR #123"
  }
}
```

#### Start an exploration

```graphql
mutation StartExploration($input: StartExplorationInput!) {
  startExploration(input: $input) {
    id
    status
    batch {
      id
      runs {
        id
        name
      }
    }
  }
}
```

Variables:

```json
{
  "input": {
    "targetId": "smt_..."
  }
}
```

Supported input fields:

* `targetId`: target to explore
* `url`: URL to explore. For an API-key caller this per-run URL is treated as an override, and is only honoured when you also send `triggerSource: "CI"` (see below). Without it the `url` is ignored and the run uses the product's base URL (a target inherits its URL from its product).
* `githubRepository`: repository in `owner/repo` format
* `githubPrNumber`: pull request number as an integer
* `serverManagedComment`: boolean
* `triggerSource`: optional enum declaring where the run was triggered. Send `CI` from any CI provider - GitHub Actions, CircleCI, GitLab CI, and so on (the deprecated alias `GITHUB_ACTION` is treated identically). **Required whenever you pass `url`** from an API-key caller: it authorises the per-run URL override. It also labels the run as CI-triggered in your dashboards. Every other origin is derived from your credentials and cannot be declared here.
* `credentialSetId`: optional string that selects a saved credential set by ID or name. Duku resolves and decrypts it server-side, so you never send passwords in the request body. Credential fields from the set take precedence over inline values.
* `inputValues`: custom values for form filling, keyed by input type or name. Use `credentialSetId` for anything secret; values sent here travel in the request body.

When you send `serverManagedComment: true` together with `githubRepository` and `githubPrNumber`, Duku posts the PR comment itself and updates the same comment when the run finishes.

{% hint style="info" %}
**PR comments are posted by the Duku GitHub App, not GitHub Actions**

The App is a one-time install on the repository that lets Duku's platform post comments through the GitHub API. It is not GitHub Actions and uses no CI minutes.

Each target posts its own sticky comment on a PR. Duku updates that comment in place when the run finishes.
{% endhint %}

#### Run all test cases

Use `runAllTestCases` to run the test cases configured on a target.

```graphql
mutation RunAllTestCases($input: RunAllTestCasesInput!) {
  runAllTestCases(input: $input) {
    success
    intentBatchId
  }
}
```

The input requires `targetId`. It optionally accepts `credentialSetId` by ID or name. It works the same way as on `startExploration`.

#### Polling for completion

After starting a run, poll the batch until its status is `completed` or `failed`.

Batch status is lowercase - `pending` and `running` are in flight, `completed` and `failed` are terminal. Run status uses the same four values. Poll no more often than every 30 seconds; explorations typically take 5 to 40 minutes. GitHub users should use [GitHub check runs](/integrations/github-check-runs.md) for merge gating.

```graphql
query BatchStatus($id: ID!) {
  batch(id: $id) {
    id
    status
  }
}
```

### Trigger from any CI

Use this flow from CircleCI, GitLab CI, Jenkins, or any other pipeline. No GitHub Action is required.

1. Exchange the API key for an access token.

```bash
DECODED_KEY=$(printf '%s' "$PLATFORM_API_KEY" | base64 --decode)
CLIENT_ID=${DECODED_KEY%%:*}
CLIENT_SECRET=${DECODED_KEY#*:}

TOKEN=$(curl -s -X POST https://auth.duku.ai/realms/duku/protocol/openid-connect/token \
  -d "grant_type=client_credentials" \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET" | jq -r '.access_token')
```

2. Create or update the target, then capture the target ID.

```bash
TARGET_ID=$(curl -s -X POST https://platform.duku.ai/graphql \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation Upsert($input: UpsertSimulationTargetInput!) { upsertSimulationTarget(input: $input) { id } }",
    "variables": {
      "input": {
        "subjectId": "sms_product_id",
        "buildKey": "github:repo=owner/repo:pr=123",
        "name": "PR #123"
      }
    }
  }' | jq -r '.data.upsertSimulationTarget.id')
```

3. Start the exploration with the target ID, URL, PR metadata, and CI trigger source. `triggerSource: "CI"` is required here because a `url` is passed.

```bash
curl -X POST https://platform.duku.ai/graphql \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"query\": \"mutation StartExploration(\$input: StartExplorationInput!) { startExploration(input: \$input) { id status } }\",
    \"variables\": {
      \"input\": {
        \"targetId\": \"$TARGET_ID\",
        \"url\": \"https://preview.example.com\",
        \"githubRepository\": \"owner/repo\",
        \"githubPrNumber\": 123,
        \"serverManagedComment\": true,
        \"triggerSource\": \"CI\"
      }
    }
  }"
```

### End-to-end cURL example

```bash
DECODED_KEY=$(printf '%s' "$PLATFORM_API_KEY" | base64 --decode)
CLIENT_ID=${DECODED_KEY%%:*}
CLIENT_SECRET=${DECODED_KEY#*:}

TOKEN=$(curl -s -X POST https://auth.duku.ai/realms/duku/protocol/openid-connect/token \
  -d "grant_type=client_credentials" \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET" | jq -r '.access_token')

curl -X POST https://platform.duku.ai/graphql \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation StartExploration($input: StartExplorationInput!) { startExploration(input: $input) { id status } }",
    "variables": {
      "input": {
        "targetId": "smt_your_target_id"
      }
    }
  }'
```

### Schema introspection

Schema introspection is disabled on the production endpoint - `__schema` and `__type` queries fail validation. Write queries against the operations documented here; if you need a schema for code generation, contact your Duku account team.

### Common errors

| Code                    | Meaning                                                                                                                                                 |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UNAUTHENTICATED`       | Missing or invalid access token                                                                                                                         |
| `FORBIDDEN`             | Insufficient permissions for this operation                                                                                                             |
| `NOT_FOUND`             | The resource does not exist, or belongs to another organisation                                                                                         |
| `BAD_USER_INPUT`        | Invalid input - including a missing URL when no override was sent and the product has no base URL                                                       |
| `CONFLICT`              | The request conflicts with the current state, such as a duplicate or a concurrent change                                                                |
| `ORG_NOT_LINKED`        | The organisation is not fully provisioned - contact support                                                                                             |
| `INTERNAL_SERVER_ERROR` | An unexpected failure. The message is always `Something went wrong. Please try again.` and `extensions.correlationId` carries an id to quote to support |

GraphQL errors are returned with HTTP 200, so check the response body for an `errors` array rather than relying on the status code. Unexpected errors are masked to `INTERNAL_SERVER_ERROR` with a fixed message; the codes above always carry a specific one.

Three common causes: sending the base64 API key as the Bearer token instead of exchanging it first returns `UNAUTHENTICATED`; naming a product that belongs to another organisation returns `NOT_FOUND`; and getting an enum's casing wrong returns no code at all - the queried field comes back `null` and the reason is in `errors[]`.
