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

# Issues API

Pull Duku findings into your tools and write triage decisions back.

Most teams use this to hand Duku's findings to whatever they already build with - a coding agent, a script, or their own issue tracker. Every entry says what is broken and carries the exact request, message, and step that produced it, so an agent can act on it without anyone re-describing the bug first.

All operations need a Bearer token. See [API Keys](/integrations/api-keys.md).

### Before you start

You need two things.

**An API key** - in the dashboard, go to **Settings → API keys → + Generate key**. It is shown once, so copy it straight away. A key can only see the organisation it was created in. See [API Keys](/integrations/api-keys.md) for how keys work.

**Your product ID** - open the product in the dashboard and take the `sms_...` value out of the URL:

```
https://app.duku.ai/p/sms_AbC123XyZ456
                      ^^^^^^^^^^^^^^^^
                      your product ID
```

#### Issues and signals

Duku reports what it finds at two levels.

* An **issue** (`isg_...`) is one problem in your app, with a written explanation and a severity.
* A **signal** (`sig_...`, or `iss_...` on older findings) is one distinct error behind it - a specific failed request, exception or console error. Every run that hits it adds an occurrence.

An issue groups one or more signals. See Issues & signals for how Duku groups errors.

#### Get everything for a product in one query

```graphql
query AllIssues($subjectId: ID!) {
  issues(subjectId: $subjectId, status: OPEN, sortBy: PRIORITY, sortDirection: desc) {
    id
    title
    description
    bucket
    signals { id }
  }
  signalsConnection(subjectId: $subjectId, status: OPEN, first: 500, sortBy: PRIORITY, sortDirection: desc) {
    pageInfo { hasNextPage endCursor }
    edges { node {
      id
      issueId
      title
      occurrences(first: 1) {
        runId
        sampleError { message actionDescription }
      }
    } }
  }
}
```

`subjectId` is your product ID - the `sms_...` in the dashboard URL.

This is the smallest useful query. Every field in [Fields you can add](#fields-you-can-add) can go into it - add only the ones you need.

The response has two lists:

* **`issues`** - the problems, one entry per underlying cause, each with a written explanation and the ids of the signals behind it.
* **`signalsConnection`** - every signal, with the detail of what failed. "Connection" is the GraphQL name for a list you page through.

Match each signal's `issueId` to an issue's `id` (or an issue's `signals[].id` to a signal's `id`). Both lists are needed - an issue lists which signals belong to it, but the error detail lives on the signal. Products that are not grouped yet return `issues` as an empty array and the full picture in `signalsConnection`, so the same query works either way.

To run it, see [Save it as JSON](#save-it-as-json) below - it covers exchanging your key for a token and posting the query.

#### Save it as JSON

Export your key, then save the query as `query.json` with your product ID as the variable:

```bash
export DUKU_API_KEY='<paste your key here>'
```

```json
{
  "query": "query AllIssues($subjectId: ID!) { ... }",
  "variables": { "subjectId": "sms_AbC123XyZ456" }
}
```

```bash
DECODED=$(printf '%s' "$DUKU_API_KEY" | base64 --decode)
TOKEN=$(curl -s -X POST https://auth.duku.ai/realms/duku/protocol/openid-connect/token \
  -d grant_type=client_credentials \
  -d "client_id=${DECODED%%:*}" \
  -d "client_secret=${DECODED#*:}" | jq -r .access_token)

curl -s https://platform.duku.ai/graphql \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d @query.json | jq '.data' > duku-issues.json
```

Re-run it on a schedule, or after each deploy, to keep a current copy alongside your own issue data.

#### Fields you can add

Add any of these to the query above. Fields with `{ }` take sub-fields - list the ones you want inside the braces.

**On each issue**

These go inside `issues { }` or `issue(id:) { }`.

| Field                                                                    | What it is                                                                                                             |
| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `id`                                                                     | The issue's identifier (`isg_...`). Pass it to `issue(id:)` and `setIssuePriority`                                     |
| `title`                                                                  | A one-line statement of the problem                                                                                    |
| `description`                                                            | What a user does, what the app does wrong, and what to look at                                                         |
| `status`                                                                 | `OPEN`, `TRIAGED`, `RESOLVED` or `IGNORED` - see [Writing back status and priority](#writing-back-status-and-priority) |
| `bucket`                                                                 | Severity - `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`                                                                         |
| `priorityScore`                                                          | A numeric priority that orders issues within the same severity                                                         |
| `category`                                                               | The kind of error, for example `API_ERROR` or `JAVASCRIPT_ERROR`                                                       |
| `categories`                                                             | Every kind of error among its signals                                                                                  |
| `incident`                                                               | `true` when Duku flagged it as an incident                                                                             |
| `signalCount`                                                            | How many signals are behind it                                                                                         |
| `occurrenceCount`                                                        | How many distinct **runs** hit it - not how many errors                                                                |
| `firstSeenAt` / `lastSeenAt`                                             | When it was first and last observed                                                                                    |
| `occurrenceBuckets(buckets:, windowDays:)`                               | Runs per time slice over a rolling window (14 days by default), oldest first - enough to draw a trend line             |
| `signals { id title errorType message url runId lastSeenAt }`            | A short summary of each signal behind it, most impactful first, including the latest error message and page            |
| `memberSignalIds`                                                        | The ids of every signal behind it - pass them to the bulk mutations                                                    |
| `representativeSignalId`                                                 | The signal that best represents it                                                                                     |
| `externalTicket { provider externalKey url remediationState createdAt }` | The latest ticket filed for it in your tracker, for example Linear                                                     |
| `filedAt`                                                                | When it was first filed to your tracker                                                                                |
| `confirmedAt`                                                            | When someone in your organisation confirmed it by filing it                                                            |
| `credentialSetCounts { name occurrenceCount }`                           | How many runs hit it per persona (credential set)                                                                      |
| `occurrences(first:) { ... }`                                            | One sample per affected run, newest first. Takes the same fields as a signal's `occurrences` below                     |

With a `status` filter, `signalCount`, `occurrenceCount` and `memberSignalIds` count only the signals in that status. `signals` always lists every signal.

**On each signal**

These go inside `signalsConnection { edges { node { } } }` or `signal(id:) { }`.

| Field                                          | What it is                                                                                                    |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `id`                                           | The signal's identifier (`sig_...`, or `iss_...` for older findings). Pass it to `updateSignalStatus`         |
| `issueId`                                      | The issue it belongs to - match it to an issue's `id`                                                         |
| `title`                                        | A one-line label for this specific error                                                                      |
| `description`                                  | A longer explanation, when there is one                                                                       |
| `status`                                       | `OPEN`, `TRIAGED`, `RESOLVED` or `IGNORED`                                                                    |
| `category`                                     | The kind of error, for example `API_ERROR` or `CONSOLE_ERROR`                                                 |
| `origin`                                       | `CLIENT_APP` for your own code, `THIRD_PARTY` for an external script, `UNKNOWN` if it could not be determined |
| `priorityScore`                                | A numeric priority                                                                                            |
| `occurrenceCount`                              | How many times this error was seen, across all runs                                                           |
| `affectedBuildsCount`                          | How many builds it was seen on                                                                                |
| `representativeUrl`                            | The page or endpoint it is most associated with                                                               |
| `firstSeenAt` / `lastSeenAt`                   | When it was first and last observed                                                                           |
| `firstSeenTargetId` / `lastSeenTargetId`       | The build it was first and last seen on                                                                       |
| `occurrenceBuckets(buckets:, windowDays:)`     | Occurrences per time slice over a rolling window, oldest first                                                |
| `credentialSetCounts { name occurrenceCount }` | How often it was seen per persona (credential set)                                                            |
| `occurrences(first:) { ... }`                  | The runs it was seen in, newest first - see the next table                                                    |

**On each occurrence**

These go inside `occurrences(first:) { }`. `first` defaults to 100 and goes up to 1000; `first: 1` returns the most recent.

| Field                                                                | What it is                                                  |
| -------------------------------------------------------------------- | ----------------------------------------------------------- |
| `runId`                                                              | The run it was captured in, for looking up in the dashboard |
| `occurredAt`                                                         | When it happened                                            |
| `credentialSetName`                                                  | The persona (credential set) the run used                   |
| `target { name version environment appVersion { label commitSha } }` | The build and release it happened on                        |
| `run { id name startTime }`                                          | The run it happened in                                      |
| `sampleError { ... }`                                                | The error itself - see the next table                       |

**On each sample error**

These go inside `sampleError { }`.

| Field                                                                    | What it is                                                                                                                                      |
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `message`                                                                | The verbatim error, including the stack for JavaScript errors                                                                                   |
| `type`                                                                   | `CONSOLE_ERROR`, `HTTP_CLIENT_ERROR`, `HTTP_SERVER_ERROR`, `JS_EXCEPTION`, `NETWORK_FAILURE`, `SCREENSHOT_REVIEW`, `RUN_REVIEW` (visual issues) |
| `actionDescription`                                                      | The step that triggered it, for example `Click on <button>: Delete`                                                                             |
| `url`                                                                    | The page it happened on                                                                                                                         |
| `requestMethod` / `requestUrl` / `statusCode`                            | The failed request                                                                                                                              |
| `sourceFile` / `lineNumber` / `columnNumber`                             | Where a JavaScript error was thrown                                                                                                             |
| `stackTrace { callFrames { functionName url lineNumber columnNumber } }` | The JavaScript call stack, frame by frame                                                                                                       |
| `level`                                                                  | The console level, for example `error`                                                                                                          |

Which fields are filled depends on the error type - an HTTP error carries the request but no source file, a console error the reverse - so expect nulls rather than treating them as missing data. The API returns the failed request's method, URL and status code, but not request or response bodies or headers.

{% hint style="info" %}
`occurrenceCount` on an issue counts distinct runs, not errors. An issue with 9 signals can report 63 - that is 63 runs affected. `type` is a string, not a fixed set, so treat unfamiliar values as "something else" rather than failing on them.
{% endhint %}

#### Narrowing it down

Both lists take `status` and sort with `sortBy` (`PRIORITY`, `LAST_SEEN`, `FIRST_SEEN`, `OCCURRENCES`). Leave out `status` to get every status. Beyond that they differ:

| List                | Also filters on                                                                                                  |
| ------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `issues`            | `category`, `categories`, `targetId`, `credentialSetId`                                                          |
| `signalsConnection` | `search`, `url`, `origin`, `priorityBucket` (`CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, `UNSCORED`), `credentialSetId` |

{% hint style="warning" %}
`sortDirection` is lowercase - `asc` or `desc` - while `sortBy` is uppercase. Sending `DESC` fails validation, and because the field is nullable you get a `null` result with the reason in `errors[]` rather than an obvious failure.
{% endhint %}

`first` defaults to 100 and is capped at 500 - ask for more and you silently get 500, so page with `after` set to `pageInfo.endCursor` rather than raising it. `signalsConnection` is forward-only: `last` and `before` are rejected. `issues` is not paged and always returns the full list.

To pull one issue on its own, pass its `id`:

```graphql
query One($id: ID!) {
  issue(id: $id) {
    title
    description
    signals { id title message }
  }
}
```

To pull one signal, use `signal(id:)` with any of the signal fields above.

#### Writing back status and priority

Statuses are `OPEN`, `TRIAGED`, `RESOLVED` and `IGNORED`.

| Status     | Use it for                                        |
| ---------- | ------------------------------------------------- |
| `OPEN`     | Newly found, not yet triaged                      |
| `TRIAGED`  | Acknowledged and prioritised                      |
| `RESOLVED` | Fix deployed, not expected to recur               |
| `IGNORED`  | Known, won't-fix, or noise you do not want to see |

**Set the status of an issue**

An issue takes its status from its signals, so you change it by setting every signal behind it in one call. This is what the dashboard does when you change an issue's status.

Get the issue's signal ids:

```graphql
query IssueSignals($id: ID!) {
  issue(id: $id) { id status memberSignalIds }
}
```

Then set them all:

```graphql
mutation ResolveIssue($ids: [ID!]!) {
  bulkUpdateSignalStatus(input: { ids: $ids, status: RESOLVED }) { id status }
}
```

Pass the `memberSignalIds` from `issue(id:)`, or from an `issues` query with no `status` or `category` filter. With either filter, `memberSignalIds` holds only the matching signals, so the rest keep their status and the issue may not move.

An issue shows the first of these statuses that any of its signals has: `OPEN`, then `TRIAGED`, then `RESOLVED`, then `IGNORED`. It stays `OPEN` while any of its signals is `OPEN`, and reads `IGNORED` only when every signal is ignored.

**Set the status of one signal**

```graphql
mutation ResolveSignal($id: ID!) {
  updateSignalStatus(id: $id, input: { status: RESOLVED }) { id status }
}
```

**Set priority**

Set an issue's priority with `setIssuePriority(id: <issue id>, input: { priority: HIGH })`. A priority you set sticks - Duku does not re-rank that issue afterwards. To set one signal's priority use `setSignalPriorityOverride(id:, input: { priority: HIGH })`, or `bulkSetSignalPriorityOverride(input: { ids, priority })` for several.

{% hint style="warning" %}
A status you set is never changed back automatically. If an issue you marked `RESOLVED` happens again, it stays `RESOLVED` - only its `lastSeenAt` and occurrence count move. It will not reappear in a `status: OPEN` query, so to catch regressions, check `lastSeenAt` on your resolved and ignored issues rather than relying on them reopening.
{% endhint %}

Status and priority writes need only the **Member** role, so an ordinary API key can triage from a pipeline.
