# Headless execution in Copilot CLI

Run copilot -p from a GitHub Actions workflow when the agent-ready label is added, with narrow tool permissions, a repo-secret token and a job that opens the pull request.

Source: https://ai-sw-factory.mellicci.dev/fundamentals/headless-execution/copilot-cli

This page covers Copilot CLI's programmatic mode. The Copilot cloud agent is a different product: you assign it issues on GitHub and it works on GitHub's servers.

## At a glance

| | Copilot CLI |
|---|---|
| Term | Programmatic mode (`copilot -p`) |
| Configured in | Command-line flags, environment variables (`COPILOT_GITHUB_TOKEN`, `COPILOT_MODEL`, `COPILOT_ALLOW_ALL`), `~/.copilot/settings.json` |
| Loads / runs | Runs the prompt, prints the response, exits; install on the runner with `npm install -g @github/copilot` |
| Scope & precedence | Flags apply to one run; a deny rule beats any allow rule, even with `--allow-all` |

## Build the scenario

The label `agent-ready` on an issue starts a workflow that implements the issue and opens a pull request.

| Step | What you write or run | Where |
|---|---|---|
| 1 | Create a fine-grained PAT with the **Copilot Requests** permission; store it as secret `COPILOT_PAT` | GitHub settings, repo secrets |
| 2 | Trigger on `labeled`, grant `GITHUB_TOKEN` least privilege, cap the run time | `.github/workflows/agent-ready.yml` |
| 3 | Check out, install pnpm dependencies, install Copilot CLI | Same file |
| 4 | Run `copilot -p` with narrow tools | Same file |
| 5 | Push `agent/issue-<number>` and open the PR | Same file |

```yaml
name: Agent ready
on:
  issues:
    types: [labeled]
permissions:
  contents: write
  pull-requests: write
  issues: read
jobs:
  implement:
    if: github.event.label.name == 'agent-ready'
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
```

```yaml
      - uses: actions/checkout@v6
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: npm install -g @github/copilot
```

The issue text reaches the shell through environment variables, not by pasting `${{ }}` into the script.

```yaml
      - name: Run Copilot CLI
        env:
          COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_PAT }}
          N: ${{ github.event.issue.number }}
          TITLE: ${{ github.event.issue.title }}
          BODY: ${{ github.event.issue.body }}
        run: |
          git config user.name github-actions[bot]
          git config user.email 41898282+github-actions[bot]@users.noreply.github.com
          git switch -c "agent/issue-$N"
          copilot -p "Implement issue #$N: $TITLE. $BODY. Run pnpm test, then commit." \
            -s --no-ask-user --allow-tool='write, shell(pnpm:*), shell(git:*)' \
            --deny-tool='shell(git push)'
```

```yaml
      - name: Push and open PR
        env:
          GH_TOKEN: ${{ github.token }}
          N: ${{ github.event.issue.number }}
          TITLE: ${{ github.event.issue.title }}
        run: |
          git push -u origin "agent/issue-$N"
          gh pr create --title "$TITLE" --body "Closes #$N"
```

## Specifics

### Running without a prompt

| Form | Behavior |
|---|---|
| `copilot -p "PROMPT"` (`--prompt`) | Runs the prompt non-interactively and exits when done |
| `echo "PROMPT" \| copilot` | Piped input works; it is ignored if `-p` is also given |
| `--no-ask-user` | The agent does not pause to ask clarifying questions |
| `--model`, `COPILOT_MODEL` | Pin a model; order: custom agent, `--model`, `COPILOT_MODEL`, `settings.json`, default |
| `-i PROMPT` | Starts an interactive session instead; not headless |

### Output and exit codes

| Flag | Effect |
|---|---|
| `-s` (`--silent`) | Only the agent's response, no stats or decoration; use it when capturing output |
| `--output-format=json` | JSONL, one JSON object per line; `text` is the default |
| `--share=PATH` | Writes the session transcript to a Markdown file after the run; `--share-gist` publishes a secret gist |

The documentation doesn't specify process exit codes for `copilot -p`, or a schema for the JSON lines. The job therefore checks the result it needs: whether `agent/issue-<number>` holds a commit, which the push step requires. Only `copilot workflow run` documents codes (`0`, `1`, `130`).

### Permissions when nobody can approve

| Mechanism | Example | Effect |
|---|---|---|
| `--allow-tool` | `'write, shell(pnpm:*), shell(git:*)'` | Runs without approval; `:*` matches all subcommands |
| `--deny-tool` | `'shell(git push)'` | Blocked, even under `--allow-all` |
| `--available-tools` / `--excluded-tools` | `'bash,edit,view,grep,glob'` | Tools the model can see at all |
| `--allow-all-tools`, `--allow-all` (`--yolo`) | | Everything; the docs advise isolated environments only |
| `--allow-url`, `--deny-url` | | Web access; deny wins |

In `-p` runs an action that isn't pre-approved is denied automatically. Read-only operations are allowed by default, so "no other tools" means no other modifying tools unless you add `--available-tools`. With `--autopilot`, the agent keeps working until it calls `task_complete`; `--max-autopilot-continues` limits the continuations, but the docs give both 5 and "unlimited" as the default.

What loads in `-p`: project MCP servers, repository hooks and project extensions load only in a trusted folder. Hooks also load with `COPILOT_ALLOW_ALL`; otherwise set `GITHUB_COPILOT_PROMPT_MODE_REPO_HOOKS`, `GITHUB_COPILOT_PROMPT_MODE_WORKSPACE_MCP` or `GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS` to `true`.

### Sessions and continuation

| Need | Use |
|---|---|
| Find the session | The `-p` exit summary prints `copilot --resume=SESSION-ID` |
| Continue the latest one | `--continue` (current directory first) |
| Continue a named one | `-n NAME` when creating; `--resume=NAME`, an ID or ID prefix |
| Exact ID | `--session-id ID` |

A bare `--resume` needs a terminal; under `-p` with several sessions the CLI exits with an error, so pass an explicit ID or use `--continue`. On a fresh Actions runner there is no earlier session to resume unless you restore the session files yourself; the documentation doesn't specify how.

### CI integration

| Topic | Documented |
|---|---|
| Install | `npm install -g @github/copilot` (Node.js 22 or later) |
| Token | `COPILOT_GITHUB_TOKEN`, then `GH_TOKEN`, then `GITHUB_TOKEN`; it silently overrides a stored login |
| PAT | Fine-grained, `github_pat_`, owned by a personal account, **Copilot Requests** permission; classic `ghp_` is not supported |
| `GITHUB_TOKEN` alternative | Permission `copilot-requests: write`; in an organization repository the policy "Allow use of Copilot CLI billed to the organization" must be on |
| Redaction | `GITHUB_TOKEN` and `COPILOT_GITHUB_TOKEN` are redacted from output; add others with `--secret-env-vars` |
| Pinning | `COPILOT_AUTO_UPDATE=false` or `--no-auto-update` |
| Recommended by GitHub | GitHub Agentic Workflows instead of raw `copilot` steps |
| Scheduling | `/every` and `/after` need an open interactive session; for a schedule, run `copilot -p` from cron or an Actions `schedule` trigger |

## Gotchas

- Issue text is untrusted input. The docs warn that workflows triggered from forks are at higher risk of prompt injection, and that running `copilot` directly gives it broad access to the workflow environment.
- A PAT runs as its creator and draws on that person's Copilot seat. An environment token silently overrides a stored login, so a stray `GH_TOKEN` can change whose seat is used.
- `shell(git:*)` allows every Git subcommand, including `git push`; the `--deny-tool` rule is what keeps pushing in the workflow.
- Without an exit-code contract, a run that changes nothing can still end green. Check for a commit before you push.
- `timeout-minutes` stops the job; the documentation doesn't specify a Copilot CLI time-limit flag.
