# Fix issues with your agent

Start your coding agent on an issue from the issue page or the CLI, hand it off to an agent running elsewhere, or watch for new issues.

The issue page and the CLI give the agent the same short task. It names the issue and its URL, and tells the agent to call `get_fix_context`, write a failing test, fix the root cause and open a pull request that references the issue URL. The exception, frames, trace and logs reach the agent through MCP, never on the command line.

`get_fix_context` returns the redacted prompt that **Copy fix prompt** copies, described in [fix prompts](/docs/features/ai-summaries#copy-fix-prompt), plus the first bad deploy and its commits, the spread across occurrences, user feedback, the owner (from an ownership rule, else CODEOWNERS, else blame on the throwing line when repository access is on) and repository paths per frame. When the top in-app frame has no source map, it adds 30 lines on each side of the throwing line read from the repository at the deploy commit, if [repository access](/docs/features/integrations/github#repository-access) is on. It calls no AI provider.

Connect your agent first. [Set up with Claude Code](/docs/agents/setup-with-claude-code), [Cursor](/docs/agents/setup-with-cursor), [Codex](/docs/agents/setup-with-codex), [VS Code](/docs/agents/setup-with-vscode) and [GitHub Copilot](/docs/agents/setup-with-copilot) each have one command.

## Fix one issue

### From the issue page

Open the issue, then **Fix with your agent**, right after **Copy fix prompt**. Pick your agent to copy the command that starts it on this issue, or the matching `npx @unhandled/cli fix` command. Paste it into a terminal in the service's repository.

### From the CLI

Sign in once. The CLI registers itself with your Unhandled server over OAuth, opens the consent page in your browser, and keeps its tokens in `~/.config/unhandled/`, readable only by you.

```bash
npx @unhandled/cli login
npx @unhandled/cli login --url https://unhandled.net
```

The CLI signs in to `--url`, or to `UNHANDLED_URL` when no flag is given, or to `https://unhandled.net`. Pass the same `--url` or `UNHANDLED_URL` to the other commands when you host your own. `--no-browser` prints the consent link instead of opening it.

Then start your agent on an issue, by URL or by ID, from the repository the issue belongs to:

```bash
npx @unhandled/cli fix https://unhandled.net/o/acme/projects/web/issues/42 --agent claude
```

The agent starts in the current directory and takes over the terminal. Add `--headless` to run it without a session, with the agent's print mode (`claude -p`, `codex exec`, `opencode run`, `gemini -p`, `cursor-agent -p`, `amp -x`). `npx @unhandled/cli issues --project web --status open` lists issues to pick from, and `npx @unhandled/cli logout` forgets the tokens.

### From a selector

An agent that is already connected can skip the ID. Ask it to fix `@top`, `@most_users`, `@latest_regression`, `@since_last_deploy` or `@mine`. Each selector picks the first matching issue, as listed under [Issues and exceptions](/docs/features/mcp-server#issues-and-exceptions).

Your agent opens the pull request with its own credentials, the same way it does for any other change. The issue URL in the pull request body leads reviewers back to the occurrence. To close the loop, end the fixing commit with a `Fixes-Unhandled: <issue ID>` trailer or call `link_pull_request`. The issue then resolves in the release that ships the fix, and [Verify a deploy](/docs/agents/verify-a-deploy) shows how to check it held.

A [GitHub](/docs/features/integrations/github) or [Linear](/docs/features/integrations/linear) issue created from Unhandled carries the same fix prompt in a collapsed section, and tells whoever picks it up to call `get_fix_context` with the issue ID.

### Supported platforms

| Command | macOS and Linux | Windows |
| --- | --- | --- |
| `login`, `logout`, `issues` | Yes | Yes |
| `mcp install` | Yes | Yes. It edits the config file under `%USERPROFILE%` or `%APPDATA%`, and prints the Claude Code command for you to run. |
| `fix`, `watch` | Yes | Not yet. Run them in WSL. |

Interactive `fix` keeps the agent in your terminal, so Ctrl-C goes to the agent as usual and the CLI exits with the agent's exit code.

## Context bundles

`get_context` returns what Unhandled knows about one thing as Markdown, for an agent to read before it edits code. It calls no AI provider.

| `kind` | `ref` | Also needs |
| --- | --- | --- |
| `issue` | Issue ID, URL, fingerprint or a selector such as `@top` | `issues:read` |
| `incident` | Incident ID or the incident URL | `alerts:read` |
| `endpoint` | `"GET /api/orders"`, a route alone or the requests page URL | |
| `trace` | 32-character trace ID or the trace URL | |
| `deploy` | Deployment ID, commit SHA or the deployment URL | `deployments:read` |
| `monitor` | Monitor ID or the monitor URL | `uptime:read` |
| `slo` | SLO ID or the SLO URL | `alerts:read` |

Every kind needs `telemetry:read`. `ref` also takes a [stable link](#stable-links) of the same kind. `budget` caps the Markdown at 4,000 (`s`), 12,000 (`m`, the default) or 30,000 (`l`) characters. Sections that don't fit are dropped from the bottom and listed in `droppedSections`, so the bundle never goes over. An issue bundle has the same sections as `get_fix_context`, without the instructions to fix it. An endpoint bundle starts with the N+1 queries and other performance issues detected on it. A ref that names more than one thing, such as a route served by `GET` and `POST`, returns an error that lists what to pass instead, and so does a URL from another Unhandled app or organization. Every kind's Markdown follows the organization's [AI data policy](/docs/features/ai-summaries#data-policy), the same as `get_fix_context`.

CI bots and scripts can read the same bundle over HTTP with an [agent identity](/docs/agents/security#headless-agents) token or an OAuth access token. A DSN never works here.

```sh
curl -H "Authorization: Bearer $UNHANDLED_TOKEN" \
  "https://unhandled.net/o/acme/projects/shop/api/context/endpoint/GET%20%2Fapi%2Forders.md?budget=s"
```

The response is `text/markdown`. The `x-unhandled-dropped-sections` header lists the sections the budget dropped. The call is checked, masked, budgeted and audited the same way as the MCP tool. Telemetry in the Markdown is fenced as untrusted data.

## Stable links

Issues, occurrences, traces, logs, deploys, alerts, incidents, monitors, outages, SLOs and endpoints in tool results carry a `url` that keeps working:

```text
https://unhandled.net/l/{kind}/{organization}/{project}/{id}?env=production&from=2026-11-01T08:00:00Z&to=2026-11-01T09:00:00Z
```

The link freezes the window the agent read, so a person who opens it sees the same page and the same time range, after signing in. Someone outside the organization gets the same not-found page as for an organization that doesn't exist. A log link also carries the log's `service` and `at`, so finding the line again reads only that service's logs at that instant.

`resolve_link` turns a link back into its kind, ids and window, with the app page and the tool call that reads it, such as `get_issue` for an issue link. It refuses links of another Unhandled app or another organization. It's in the `perf` toolset and every toolset finds it through `search_tools`.

## Hand off an issue

**Fix with your agent** on the issue page also hands the issue to an agent that runs somewhere else. So does the MCP tool `handoff_issue`. None of the targets calls an AI provider. Each hand-off adds a line to the issue activity.

| Target | What happens |
| --- | --- |
| Prompt | Returns the agent fix prompt, the same text as **Copy fix prompt** |
| GitHub | Opens a GitHub issue in the project's repository with the `unhandled-fix` label. If the issue already has a linked GitHub issue, that issue gets the label instead. The [Claude Code Action](https://github.com/anthropics/claude-code-action) workflow below picks it up |
| Copilot cloud agent | Opens or reuses the GitHub issue and assigns it to Copilot. Only on servers that turned it on, see [Set up with GitHub Copilot](/docs/agents/setup-with-copilot#hand-an-issue-to-copilot) |
| Cursor cloud agent | Starts a [Cursor cloud agent](https://cursor.com/docs/cloud-agent) on the project's repository with the fix prompt, on the organization's Cursor API key. It opens a pull request when it is done. Shown once an admin stored a key, see below |
| Webhook | Sends an `issue.handoff` event to a webhook channel on [version `2026-11`](/docs/features/notifications#versioned-events-for-agents). Its signed `context_url` returns the fix prompt for one hour |

The GitHub and Cursor targets need the [GitHub integration](/docs/features/integrations/github) and a repository linked to the project. Without one, the hand-off says to link a repository first.

An admin picks each project's default target under **Settings, Integrations, Agent hand-off**, or with `set_project_handoff_target`. `handoff_issue` without a `target` uses that default, and the prompt when none is set.

Over MCP, the GitHub, Cursor and webhook targets send data out of Unhandled. They need `confirm: true`. When the organization requires approval for sensitive actions, which is the default, a person approves them first.

### Cursor cloud agent

An admin pastes a Cursor API key under **Settings, Integrations, Agent hand-off**. Saving or removing it needs a sign-in from the last 15 minutes or your password, the same as the AI provider key. Unhandled stores it encrypted, shows only its last four characters, and records each change in the organization's audit trail. MCP tools can neither read nor set it.

A hand-off to Cursor starts an agent through Cursor's API (`POST /v1/agents` with `autoCreatePR`), named `Fix Unhandled issue <id>`. The prompt is the fenced fix prompt. The activity entry links to the agent on cursor.com, and `handoff_issue` returns its `id` and `url`.

The key spends your Cursor usage, so over MCP the hand-off always needs `confirm: true`, a person's approval where the organization requires approval, and is refused for 10 minutes after the connection received a secret, like any call that sends data out.

### Automation levels

Each project has an automation level, set by an admin under **Settings, Integrations, Agent hand-off** or with `set_project_automation`. Every change goes to the organization audit log with its old and new values.

| Level | What happens |
| --- | --- |
| L0 Observe | Findings, suspect commits and the fix prompt. Nothing is handed off |
| L1 Suggest | **Fix with your agent** and `handoff_issue` hand off when a person asks |
| L2 Auto-draft | Unhandled hands new or regressed issues to the project's default target on its own, and asks for a draft pull request |
| L3 Auto-PR | As L2, and asks for a pull request ready for review, with suggested reviewers from the project's ownership rules listed in its body |

Projects that existed before automation levels stay on L0. New projects start on L1. Nothing is ever merged at any level.

L2 and L3 are on the Business plan. They hand off an issue only when it is open, priority high or critical, new or regressed in the last hour, has an in-app frame with a line number, and the project has a linked GitHub repository and a GitHub or webhook default target. They run at most 5 times a day per project (`dailyCap`, up to 50), at most once per issue a day, and never during the project's quiet hours (whole UTC hours).

An automated run can read Unhandled and comment on issues. It cannot change anything else in Unhandled on its own. Create the agent identity the run's agent uses with **Used by automated runs** turned on: any other write from that identity, such as resolving the issue or changing a monitor, returns `approval_required` and waits for a person, even with `confirm: true`.

### Fix attempts

Each attempt at fixing an issue is a remediation run. It moves from candidate through dispatched (handed off), PR open, PR merged and awaiting deploy to verifying, and ends verified fixed, fix failed, or declined when its pull request closes unmerged. Unhandled moves it on its own from hand-offs, `link_pull_request`, `Fixes-Unhandled` trailers, GitHub pull request events, deploys and [fix verification](/docs/features/issues). Every step shows in the issue's activity, and `get_issue` returns the current run as `remediation`. A failed fix starts the next attempt.

The next attempt learns from the last ones. `get_fix_context` lists earlier attempts at the issue that failed or were declined, and failed fixes of issues of the same type in the last 90 days, with the pull request, the merged commit's message and why it failed, as `previousAttempts` and a **Previous fix attempts** section of its markdown. `get_remediation_stats` reports, per agent kind, how many attempts opened a pull request, were merged, declined, verified or failed, the acceptance and fix-failed rates and the median time from the issue appearing to its fix being verified.

### The Claude Code Action workflow

Add this workflow to the repository the project is linked to, as `.github/workflows/unhandled-fix.yml`. Store an Anthropic API key as the `ANTHROPIC_API_KEY` repository secret.

```yaml
name: Unhandled fix
on:
  issues:
    types: [labeled]
jobs:
  fix:
    if: github.event.label.name == 'unhandled-fix'
    runs-on: ubuntu-latest
    permissions:
      contents: write
      issues: write
      pull-requests: write
      id-token: write
    steps:
      - uses: actions/checkout@v5
      - uses: anthropics/claude-code-action@v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
          label_trigger: unhandled-fix
          allowed_bots: unhandled-net[bot]
```

The Unhandled GitHub App opens and labels the issue, so the action has to accept it as the actor. `allowed_bots` names the app's bot. When you host Unhandled yourself, use `<GITHUB_APP_SLUG>[bot]`. Claude Code reads the issue, including the fix prompt in its collapsed section, and pushes a branch for a pull request. Unhandled never writes code, opens pull requests or merges.

The telemetry in the issue sits inside fences that a trusted line marks as untrusted data. The agent should treat it as data, never as instructions.

## Run without a browser

Cloud agents and CI cannot sign in through a browser. Give them an [agent identity](/docs/features/mcp-server#agent-identities) token instead, under **Settings > MCP > Agent identities**. [Codex](/docs/agents/setup-with-codex#without-a-browser) and [GitHub Copilot](/docs/agents/setup-with-copilot) have their own setups. This job asks Claude Code to fix the top regression from GitHub Actions. Store the token as the `UNHANDLED_TOKEN` repository secret:

```yaml
name: Fix top regression
on: workflow_dispatch
jobs:
  fix:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v5
      - run: npm install -g @anthropic-ai/claude-code
      - run: |
          claude mcp add --transport http unhandled https://unhandled.net/mcp/x/debug \
            --header "Authorization: Bearer $UNHANDLED_TOKEN"
          claude -p "Call get_fix_context for @latest_regression, reproduce it with a failing test, fix the root cause and open a pull request" \
            --allowedTools "mcp__unhandled__get_fix_context,Edit,Bash(npm test:*),Bash(git:*),Bash(gh pr create:*)"
        env:
          UNHANDLED_TOKEN: ${{ secrets.UNHANDLED_TOKEN }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GH_TOKEN: ${{ github.token }}
```

## Agent inbox

A local agent, such as a Claude Code `/loop` session, cannot receive webhooks. It pulls work from the agent inbox instead. Every issue that is new, regressed or escalating gets one work item. An issue that already has an open or claimed item keeps that one.

| State | Meaning |
| --- | --- |
| `open` | Waiting for an agent |
| `claimed` | Leased to one MCP connection until its lease ends |
| `handed_off` | Sent to an agent with `handoff_issue` or **Fix with your agent** |
| `done` | Completed with an outcome: `fixed`, `wont_fix` or `needs_human` |

Connect a loop to the `inbox` [toolset](/docs/features/mcp-server#toolsets), `/mcp/x/inbox`, to list only the tools it needs.

- `list_agent_work` lists items, open ones first in queue order. `state` picks `claimed`, `handed_off`, `done` or `all`.
- `claim_work_item` claims an item, or the oldest open one when you pass no `item`. Two agents never hold the same item: one claim wins and the others get an error naming who holds it. The lease lasts `leaseSeconds`, 30 minutes by default and 2 hours at most. Claim the item again before it ends to renew it. An expired lease puts the item back to `open`.
- `complete_work_item` finishes a claimed or handed-off item. `pullRequest` links the pull request the way `link_pull_request` does, so the issue resolves in the release that ships it. Only the connection that holds the claim can complete it.
- `wait_for` blocks for up to 20 seconds until an item opens, then returns it with a `waitCursor` for the next call. Without a cursor it returns every open item at once. It is the polling stand-in until MCP subscriptions ship.

Claims and completions need `issues:write`; listing and waiting need `issues:read`. Every claim, completion, hand-off and expired lease lands in the issue activity with who made it, including the MCP app. An expired lease shows as Unhandled's own change. Queueing adds no line, because the issue's own new, regressed or escalating line already says it. The inbox bell has an **Agent work** tab next to the notifications.

A loop for a local agent:

```text
1. wait_for (pass the last waitCursor as cursor)
2. claim_work_item, then get_fix_context for its issue
3. fix it, open a pull request, complete_work_item with outcome and pullRequest
4. claim_work_item again until item is null, then back to 1
```

## Watch for new issues

`unhandled watch` polls a project for new and regressed issues and starts your agent headless on each one, one issue at a time.

```bash
npx @unhandled/cli watch --agent claude --project web --dry-run
npx @unhandled/cli watch --agent claude --project web --interval 60s --max-runs 5
```

- It hands over open issues first seen or regressed since its last poll, oldest first. The first poll starts from now, so it skips the existing backlog. `--status` belongs to `unhandled issues`. Watch always looks at open issues.
- It keeps a cursor and every issue it handled in `~/.local/state/unhandled/watch-<org>-<project>-<hash>.json`, where the hash covers your server URL, so an issue it already handed to the agent is not run again, even after a restart. When that issue regresses later, it runs once more for that regression.
- `--interval` defaults to `60s`, with a floor of `5s`. It takes `30s`, `5m`, `1h` or bare seconds.
- `--dry-run` lists the issues it would hand over and starts nothing. Run it first.
- `--max-runs` stops after that many agent runs. With no flag it runs until you stop it.
- Errors back off exponentially.
- The agent runs in its own process group, so `Ctrl+C` reaches only the CLI. The first one stops polling and waits for the running agent to finish. A second one stops the agent too, and that issue runs again next time. The CLI exits with code 130.
- `--agent-args "<args>"` appends flags to the agent's command, after the task. It is split like a shell would split it, without expanding variables or globs, for example `--agent-args "--permission-mode acceptEdits"`.

> **Warning: A headless agent acts without asking**
>
> Watch mode runs an agent with nobody at the keyboard, on text that came from production. Read [Security](/docs/agents/security#headless-agents) before you leave it running.
