# Connect GitHub Actions jobs with workload identity

The [`speakeasy-api/device-agent-action`](https://github.com/speakeasy-api/device-agent-action) step signs a GitHub Actions job in as an [agent identity](/docs/ai-control-plane/identity/agents) with the job's own GitHub OIDC token, so no Speakeasy secret is stored. Later steps run Claude Code or Codex with the agent's plugins, MCP servers, and hooks.

This guide covers:

- [Access requirements](#access-requirements)
- [How it works](#how-it-works)
- [Prerequisites](#prerequisites)
- [Set up the agent](#set-up-the-agent)
- [Inputs](#inputs)
- [Outputs](#outputs)
- [Verify the job](#verify-the-job)
- [Troubleshooting](#troubleshooting)
- [Limits](#limits)
- [Revoke access](#revoke-access)

## Access requirements

> The setup needs `workload:write`, and permission to give the agent the device agent permissions listed under [prerequisites](#prerequisites). Organization admins have both. See [workload identity](/docs/ai-control-plane/identity/agents/workload-identity#access-requirements).

## How it works

- The setup trusts the GitHub OIDC issuer for the organization and adds an access rule that admits the repository's jobs as the agent.
- In the job, the action installs `speakeasyd` and writes a managed configuration that holds no secret, only the organization and the hooks project.
- The action's one-time sync requests a GitHub OIDC token, exchanges it at the organization's device agent endpoint for a 15-minute Speakeasy session, and fetches the agent's plugins.
- Each Speakeasy MCP server is written into Claude Code and Codex with a header helper, `speakeasyd mcp-token`, which gets a fresh token every time the tool connects. No MCP token is written to disk or to the job log.
- The action leaves `speakeasyd` running for the rest of the job, so tools and hooks in later steps can get tokens even when the step has no GitHub OIDC variables. For example, `anthropics/claude-code-action` removes those variables. The runner stops `speakeasyd` when the job ends.

## Prerequisites

| Requirement | Detail |
| --- | --- |
| Runner | A Linux or macOS runner, x64 or ARM64, on github.com. Windows runners and GitHub Enterprise Server are not supported. |
| AI tool | Claude Code 2.1.238 or later, or Codex 0.152.0 or later, installed in an earlier step of the same job. Cursor and OpenClaw have no header helper, so a job gets no Speakeasy MCP servers in them. |
| Device agent | `speakeasyd` 0.1.54 or later. The action installs the latest stable release unless you pin one with `version`. |
| Job permissions | `id-token: write`, so the job can request its OIDC token, and `contents: read`. |
| Agent permissions | `org:device_agent_sync` for the whole organization, to fetch plugins, and `org:hooks_ingest` on the hooks project, to send hook events. |
| Plugins | Every MCP server the job should call is in a plugin delivered to the agent. The job gets its MCP servers only from these plugins. See [what the job gets](#what-the-job-gets). |
| MCP access | The agent may connect to each of those servers, selected under **MCP Selection** when you create or edit the agent. |

> A job can call an MCP server only when both are true: the server is in a plugin delivered to the agent, and the agent may connect to it. Selecting a server under **MCP Selection** covers only the second. It grants `mcp:connect`, which makes the server reachable through a gateway but doesn't put it in the job.

## Set up the agent

Open [**Identity > Agents**](https://ai.speakeasy.com/@self/projects/default/identities/agents) and select **New agent identity**. Name the agent and, under **MCP Selection**, select the MCP servers the job may call. Each one must also be in a plugin delivered to the agent: see [what the job gets](#what-the-job-gets). On the **Agent sign-in** step, choose **GitHub Actions**.

### Repository and jobs

Enter the repository's **Owner** and **Repository** with the same capital letters GitHub shows. Speakeasy compares them exactly. Repositories created, renamed, or transferred on github.com since July 15, 2026 carry immutable IDs in their tokens. For those, add `@` and the ID to both names, which the setup shows how to look up.

Choose **Which jobs** the rule admits:

| Choice | Rule | Admits |
| --- | --- | --- |
| **Whole repository** (default) | `repo:OWNER/REPO:*` | Every branch, tag, environment, and pull request job of the repository. |
| **One branch** | `repo:OWNER/REPO:ref:refs/heads/BRANCH` | Jobs on that branch that use no environment. Pull request jobs are refused. |
| **One environment** | `repo:OWNER/REPO:environment:ENVIRONMENT` | Only jobs that use that environment. GitHub starts them only after the environment's protection rules pass. |
| **Pull requests** | `repo:OWNER/REPO:pull_request` | Only pull request jobs that use no environment. A pull request job runs the workflow as its branch has it, so nobody reviews it first. |

> Anyone who can push a branch to the repository can run a job that the whole-repository or pull request rule admits, and use the agent's access. Pull requests from forks get no OIDC token on the `pull_request` event, but a `pull_request_target` workflow with `id-token: write` is admitted, so do not use that event. To put a review in front of the agent's access, choose **One environment** and set its protection rules under the repository's **Settings > Environments** before adding the workflow.

Select **Connect GitHub Actions**. The setup checks that the agent can be given both permissions, then creates the agent, trusts the GitHub issuer at the organization level, saves the rule, and grants the permissions.

### What the job gets

This step lists the plugins installed for **Everyone** in the project, and the MCP servers in them. The list is partial: it leaves out plugins from roles the agent holds and from other projects. A job receives:

- Plugins delivered to the agent: those assigned to **Everyone** or to a role the agent holds, from any project, as **Required** or **On by default**. A plugin that is only **Available** is never installed on a job, because nobody there can turn it on. Plugins can't be assigned to an agent directly, so assign the plugin to a role and [give the agent that role](/docs/ai-control-plane/identity/agents#set-the-agent-policy). See [plugin assignments](/docs/ai-control-plane/mcp-gateway/plugins#assignments).
- The Speakeasy-hosted MCP servers in those plugins, and no others. Each is named `speakeasy-` and the server's slug in the tool's configuration, such as `speakeasy-linear-1a2b`, with a number added when two servers share a name. A server selected under **MCP Selection** that is in none of these plugins doesn't reach the job.
- The Speakeasy observability plugin, which sends the job's hook events, unless the project disabled it.

The step also names the servers the job can't use. A server in these plugins that the agent may not connect to shows in the job's tools as needing authentication: go back to **MCP Selection** and add it. For the servers a job skips, see [limits](#limits).

### Add the workflow

The **Add the workflow** step generates the workflow for Claude Code or Codex. Copy it from the dashboard. For a branch rule, the workflow runs on pushes to that branch only. For an environment rule, it adds `environment:` to the job. For the pull request rule, it runs on pull requests only. The example below uses the whole-repository rule, with `ORGANIZATION_ID` and `PROJECT_SLUG` as placeholders for the values the dashboard fills in.

For Claude Code, add `ANTHROPIC_API_KEY` to the repository's Actions secrets. Speakeasy access uses the job's GitHub token separately.

```yaml
name: Speakeasy

on:
  push:
    branches: [main]
  pull_request:

jobs:
  agent:
    if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
    runs-on: ubuntu-latest
    permissions:
      id-token: write # lets the job request its GitHub OIDC token
      contents: read
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
        with:
          persist-credentials: false
      # Install the tool and create its configuration directory before Speakeasy syncs.
      - name: Install Claude Code
        run: |
          npm install -g @anthropic-ai/claude-code@2.1.314
          mkdir -p "$HOME/.claude"
      # Installs the latest stable speakeasyd; add version: <x.y.z> to pin one.
      - name: Connect to Speakeasy
        id: speakeasy
        uses: speakeasy-api/device-agent-action@v0.1
        with:
          organization-id: ORGANIZATION_ID
          project: PROJECT_SLUG # hook events go to this project
      # Lists the agent's Speakeasy MCP servers; each one connects with a fresh token.
      - run: claude mcp list
      - name: Run Claude Code
        uses: anthropics/claude-code-action@6d5ae526d25ce88daa2200be073e1a373167f064 # v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
          github_token: ${{ github.token }}
          prompt: Describe this repository and list the Speakeasy MCP tools available to you. Do not modify files.
```

`@v0.1` follows the latest v0.1.x release of the action. Teams that require commit pins can pin a release's commit instead, with a `# v0.1.x` comment.

To let Claude call a Speakeasy tool without a permission prompt, add `claude_args: --allowedTools mcp__SERVER_NAME` to the last step, using a server name that `claude mcp list` prints.

For Codex, add `OPENAI_API_KEY` to the repository's Actions secrets. The workflow differs in three steps: Codex gets a `CODEX_HOME` shared with the device agent, the list step runs `codex mcp list`, and the last step runs `openai/codex-action`:

```yaml
      - name: Install Codex
        run: |
          npm install -g @openai/codex@0.166.0
          echo "CODEX_HOME=$RUNNER_TEMP/speakeasy-codex" >> "$GITHUB_ENV"
          mkdir -p "$RUNNER_TEMP/speakeasy-codex"
      # ...Connect to Speakeasy, unchanged...
      - run: codex mcp list
      - name: Run Codex
        uses: openai/codex-action@bdf19a4a223ec2549a3e2274a0cf61556bc07675 # v1
        with:
          openai-api-key: ${{ secrets.OPENAI_API_KEY }}
          codex-home: ${{ env.CODEX_HOME }}
          prompt: Describe this repository and list the Speakeasy MCP tools available to you. Do not modify files.
```

Keep the steps in this order, in one job. The action writes configuration only for tools installed before it, and the tool steps after it rely on the `speakeasyd` it leaves running.

## Inputs

| Input | Required | Description |
| --- | --- | --- |
| `organization-id` | Yes | The Speakeasy organization ID. The dashboard fills it in. |
| `project` | No | The project slug that receives the job's hook events. The agent needs `org:hooks_ingest` on it. Leave it empty to turn hook events off. |
| `control-plane-url` | No | Defaults to `https://ai.speakeasy.com`. The token audience is built from it, so set it only for a deployment that serves the device agent API at another address. |
| `version` | No | The `speakeasyd` release to install. Defaults to `latest`, the latest stable release. Set a stable version, 0.1.54 or later, for reproducible runs: pinning the action to a commit does not pin `speakeasyd`. |
| `speakeasyd-binary` | No | Path to a `speakeasyd` already on the runner, used instead of a release. For testing unreleased builds only. |

## Outputs

| Output | Description |
| --- | --- |
| `report` | Path to the JSON report from `speakeasyd sync --once`: the control plane, the agent the job signed in as, and each tool's status. |
| `skipped` | Set to `true` when the step skipped, for example on a pull request from a fork. |
| `daemon` | Set to `true` when `speakeasyd` is running for later steps to get tokens from. |
| `version` | The `speakeasyd` version installed, such as `X.Y.Z`. Empty when `speakeasyd-binary` is set. |

Read them in later steps as `steps.speakeasy.outputs.NAME`, using the `id` the generated workflow gives the step.

## Verify the job

- **Sign-in and plugin sync.** The **Connect to Speakeasy** step log prints the same summary as `report`, one line per tool. A status of `armed` means the tool is configured.
- **MCP servers.** The `claude mcp list` or `codex mcp list` step lists each `speakeasy-` server.
- **An MCP call.** Ask the tool to use one of the agent's tools. On the agent's page, **Overview** counts the call under **Tool calls** and lists it under **Tool logs**.
- **Hook events.** With `project` set, the job's session appears under **Agent sessions** on the agent's page, and in that project's **Observability > Agent Sessions**.

## Troubleshooting

| What the job shows | What to do |
| --- | --- |
| Notice: skipped on a pull request from a fork | Nothing. GitHub gives fork pull requests no OIDC token. |
| Error: the job has no GitHub OIDC token | Add `id-token: write` to the job's `permissions`. |
| Error: Speakeasy refused this job's GitHub token | Compare the subject the error prints with the agent's access rule, including capital letters and IDs. A job that uses an environment is named by the environment, not its branch. Speakeasy answers every refusal the same way, so also check that the GitHub issuer is trusted for the organization, not only for one project. |
| Error naming `org:device_agent_sync` | Give the agent that permission. The agent's owner must hold it too. |
| Error: did not pick up the GitHub identity | The installed `speakeasyd` predates GitHub Actions support. Use `latest`, or set `version` to 0.1.54 or later. |
| Error: a `speakeasyd` version is not a stable release, or is blocked | Set `version` to another stable release, or to `latest`. |
| Error: a `speakeasyd` SHA-256 or size doesn't match | The download doesn't match the Speakeasy release records. Contact Speakeasy rather than working around it. |
| Warning: a tool is not set up | Install the tool before **Connect to Speakeasy**, or read the `report` output for its status. |
| Warning: hook events are off | Set the `project` input. |
| Warning: `speakeasyd` did not start | Later steps that can't request a GitHub token, such as `anthropics/claude-code-action`, can't reach Speakeasy. Read the daemon log the warning names. |
| An MCP server shows as needing authentication | Edit the agent and add that server under **MCP Selection**. A server that signs in upstream with a person's account may not work in a job. See [limits](#limits). |
| An expected server is missing | Put the server in a plugin delivered to the agent. Selecting it under **MCP Selection** isn't enough. See [what the job gets](#what-the-job-gets) and [limits](#limits). |

## Limits

- **No MCP gateways yet.** [Gateway endpoints](/docs/ai-control-plane/mcp-gateway/gateway-endpoints) and the [organization gateway](/docs/ai-control-plane/mcp-gateway/organization-gateway) don't accept workload sessions, and the agent's own gateway takes only an agent API key. The job skips the gateways in its plugins and the servers behind them. It gets a server only when a plugin lists that server directly.
- **Servers need a Speakeasy authorization server.** The job gets each server's token from one, so it skips a server without one.
- **No sign-ins during a job.** Nobody in a job can complete an upstream OAuth sign-in. A server that signs in upstream with a person's account can use only an account the agent's owner already connected, so plan on it not working in a job. See [servers with per-user OAuth](/docs/ai-control-plane/identity/agents/workload-identity#servers-with-per-user-oauth). A server with a shared credential, such as a service account, needs no person.
- **Hook events need the current hooks.** They need version 0.3.41 or later of the observability plugin's hooks, which Speakeasy rolls out to organizations in stages.
- **Codex telemetry is off.** Codex can't send a per-connection token to its OpenTelemetry exporter, so on a job only its hook events are sent.

## Revoke access

On the agent's page, under **Credentials**, select **Remove** on GitHub Actions under **Federated access** to stop the repository's jobs from signing in as the agent. To stop a job that is already running, [suspend the agent](/docs/ai-control-plane/identity/agents#suspend-revoke-or-delete-an-agent). See [change or remove access](/docs/ai-control-plane/identity/agents/workload-identity#change-or-remove-access).

## Next steps

- Read [Workload identity](/docs/ai-control-plane/identity/agents/workload-identity) for how trusted platforms, access rules, and token exchange work.
- Read [Authorizing agents](/docs/ai-control-plane/identity/agents/authorizing-agents) to scope the agent the job acts as.
- Read the [Device agent](/docs/ai-control-plane/reference/device-agent) reference for how `speakeasyd` installs plugins and hooks on developer machines and cloud sandboxes.

---

Index of every Speakeasy page for agents: [llms.txt](https://www.speakeasy.com/llms.txt). AI control plane and MCP gateway content in one file: [llms-full.txt](https://www.speakeasy.com/llms-full.txt). Any page is available as markdown by appending `.md` to its URL or by requesting it with `Accept: text/markdown`.
