Building a plan → do workflow with Claude Code Action and GitHub Issues

Storyie Engineering Team
6 min read

How we wired Claude Code Action into our GitHub Issues flow so a label triggers a two-phase loop: Claude proposes a plan first, a human reviews it, then Claude implements and opens the PR.

Building a plan → do workflow with Claude Code Action and GitHub Issues

Storyie is a pnpm monorepo — web, mobile, and several shared packages — and the surface area makes ad-hoc AI coding expensive. Point an agent at the wrong abstraction layer and you end up with a PR that looks correct but quietly breaks the Expo build. We needed a way to let Claude do real implementation work while keeping a human in the loop before any code landed.

The answer turned out to be simpler than we expected: two GitHub Labels and a three-job Actions workflow using Claude Code Action.

TL;DR

  • Attach a plan label to any Issue → Claude reads the codebase and posts a concrete implementation plan as an Issue comment. No code is written.
  • Review the plan, push back if needed, then attach do → Claude follows the plan, runs type-check and lint, and opens a PR.
  • Mention @claude anywhere in an Issue or PR → Claude responds in context.

| Job | Trigger | contents permission | --max-turns | Output |
|---|---|---|---|---|
| plan | issues.labeled: plan | read | 10 | Issue comment |
| do | issues.labeled: do | write | 50 | Branch + PR |
| interactive | @claude mention | write | (default) | Comment or commit |

Why two phases

The most common AI coding failure is "confidently wrong direction." Claude will write a hundred lines of well-formatted TypeScript in the wrong package before you can blink. A planning phase costs almost nothing — ten turns of reading files and posting a comment — and it surfaces the interpretation problem before any code is written.

Specifically, plan forces three things:

  1. Requirement alignment — Claude's reading of the Issue is visible before it acts on that reading.
  2. Blast radius preview — the plan names which files and packages will be touched. In a monorepo this matters: a change to packages/lexical-common that adds a "use client" directive would break Expo.
  3. Cheap course correction — redirecting a plan is a one-line comment; redirecting a half-done PR is a full review cycle.

We considered auto-advancing from plan to do if no feedback arrived within a time window. We rejected it. The whole point is the human checkpoint.

Workflow structure

One file, three jobs.

.github/workflows/claude-code-issues.yml

Triggers

on:
  issues:
    types: [labeled]
  issue_comment:
    types: [created]
  pull_request_review_comment:
    types: [created]
  pull_request_review:
    types: [submitted]

issues.labeled drives the two main phases. The comment events drive the interactive job. No polling, no scheduled runs.

Job 1: Plan

jobs:
  plan:
    if: github.event_name == 'issues' && github.event.label.name == 'plan'
    runs-on: self-hosted
    permissions:
      contents: read
      issues: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: anthropics/claude-code-action@v1
        with:
          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          track_progress: true
          prompt: |
            REPO: ${{ github.repository }}
            ISSUE NUMBER: ${{ github.event.issue.number }}
            TITLE: ${{ github.event.issue.title }}
            BODY: ${{ github.event.issue.body }}

            Analyze this issue and produce an implementation plan:

            1. Parse the requirements
            2. Identify affected files and packages
            3. List concrete implementation steps
            4. Call out risks and cross-platform concerns
            5. Describe the test strategy

            Post the plan as an Issue comment. Do not write or modify any code.

          claude_args: |
            --max-turns 10
            --allowedTools "Read,Bash(gh issue comment:*),Bash(gh issue view:*)"

Two design decisions worth explaining:

permissions: contents: read is not just documentation — it means the token GitHub injects into the runner physically cannot push commits or create branches. Even if Claude tried, the API call would fail. The constraint is enforced at the permissions layer, not just the prompt.

--allowedTools "Read,Bash(gh issue comment:*),Bash(gh issue view:*)" closes the loop on the tool side. Claude cannot call Edit, Write, or any Bash variant that modifies the filesystem. Read covers full codebase exploration; the two gh patterns let it post the result and look up additional Issue context.

Job 2: Do

 do:
    if: github.event_name == 'issues' && github.event.label.name == 'do'
    runs-on: self-hosted
    permissions:
      contents: write
      issues: write
      pull-requests: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: pnpm/action-setup@v4
        with:
          version: 10
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm build:packages

      - uses: anthropics/claude-code-action@v1
        with:
          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          track_progress: true
          prompt: |
            REPO: ${{ github.repository }}
            ISSUE NUMBER: ${{ github.event.issue.number }}
            TITLE: ${{ github.event.issue.title }}
            BODY: ${{ github.event.issue.body }}

            Implement this issue:

            1. If a plan exists in the Issue comments, follow it
            2. Create a branch and implement the changes
            3. Follow the coding guidelines in CLAUDE.md
            4. Verify pnpm type-check && pnpm lint pass cleanly
            5. Add or update tests as needed
            6. Open a PR linked to this issue ("Closes #<number>")

            Use Conventional Commits for all commit messages.

          claude_args: |
            --max-turns 50
            --allowedTools "Read,Edit,Write,MultiEdit,Bash(pnpm:*),Bash(npx:*),Bash(node:*),Bash(ls:*),Bash(git:*),Bash(gh pr create:*),Bash(gh issue comment:*),Bash(gh issue view:*)"

The expanded --allowedTools for do deserves a closer look:

  • Edit,Write,MultiEdit — file modifications. MultiEdit in particular speeds up large refactors that touch many files.
  • Bash(pnpm:*) — runs type-check, lint, build:packages, and individual package tests. Claude runs these before committing, so the PR arrives already clean.
  • Bash(git:*) — branch creation, commits, push.
  • Bash(gh pr create:*) — opens the PR with the correct Issue reference.

Bash(rm:*) is intentionally absent. If a file needs to be deleted, Claude uses git rm (covered by Bash(git:*)) or clears the content with Edit. We do not want an accidental rm -rf pattern available.

The prompt line "if a plan exists in the Issue comments, follow it" is what connects the two phases. Claude Code Action can read Issue comments as part of its codebase context, so any clarifications you added to the plan — or constraints you left in reply — are automatically in scope.

Job 3: Interactive

 interactive:
    if: |
      (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
      (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
      (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude'))
    runs-on: self-hosted
    permissions:
      contents: write
      issues: write
      pull-requests: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: anthropics/claude-code-action@v1
        with:
          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}

No prompt field. When prompt is absent, Claude Code Action enters interactive mode and handles the @claude mention directly. This gives you:

  • Redirect a plan mid-discussion: @claude actually, can you look at the subscription package instead?
  • Request PR changes in a review: @claude this type assertion looks wrong, can you fix it without the cast?
  • Ask codebase questions from an Issue: @claude where does the image upload command originate?

CLAUDE.md as ambient context

Claude Code Action reads CLAUDE.md from the repository root on every run. Anything written there applies to all three jobs without repeating it in individual prompts. We put:

## Coding Style
- Formatter/linter: Biome
- Language: TypeScript strict
- Naming: React components PascalCase; files/dirs kebab-case

## Commands
- `pnpm type-check`: TypeScript checks
- `pnpm lint` / `pnpm lint:fix`: Biome linter

## Cross-platform Package Rules
- `lexical-common`, `subscription`, `theme`, `config` must remain platform-agnostic
- No "use client", "use server", or "use dom" directives in shared packages
- No next/* or expo-* imports in shared packages

For a monorepo, the cross-package boundary rules are the most valuable thing to document here. "No platform directives in shared packages" is the kind of constraint that is obvious to a human who has been burned by it once but invisible to an agent that has not.

Authentication

Three options:

| Mode | When to use |
|---|---|
| anthropic_api_key | Pay-per-token, no subscription needed |
| claude_code_oauth_token | Uses your Claude Pro/Max subscription |
| Bedrock / Vertex | AWS or GCP enterprise routing |

We use the OAuth token. It draws from the subscription we already pay for and requires no separate billing setup. Set CLAUDE_CODE_OAUTH_TOKEN as a repository secret; the Action consumes it directly.

- uses: anthropics/claude-code-action@v1
  with:
    claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}

Things that bit us

Write if conditions on one line

GitHub Actions evaluates if expressions differently when the value is a YAML block scalar (|). Multi-line expressions that look correct fail silently — the job shows as failed immediately, no steps run, no log output to help you diagnose.

# Breaks with no useful error
if: |
  github.event_name == 'issues' &&
  github.event.label.name == 'plan'

# Works
if: github.event_name == 'issues' && github.event.label.name == 'plan'

The interactive job has a legitimately long condition. We kept it on multiple lines by wrapping in ( ... ) within the block scalar — that evaluates correctly.

Always set fetch-depth: 0

- uses: actions/checkout@v4
  with:
    fetch-depth: 0

Without this, actions/checkout does a shallow clone. Claude can still read files, but git log and git blame return incomplete history. The plan job in particular uses historical context to understand the intent behind existing code, which is harder with a truncated graph.

What this looks like in practice

A typical Issue lifecycle now looks like:

  1. Write an Issue describing a feature or bug. No special format required — write it the way you would for a human reviewer.
  2. Add the plan label. Within a few minutes, Claude posts a comment with a numbered implementation plan, the list of affected files, and any cross-platform concerns it flagged.
  3. Read the plan. If the direction is wrong, reply with a correction and it will respond interactively. If it looks good, continue.
  4. Add the do label. Claude creates a branch, implements the plan, runs pnpm type-check && pnpm lint, and opens a PR with Closes #<number> in the description.
  5. Review the PR normally. Use @claude in review comments to request changes inline.

The workflow file itself is around 100 lines. The ongoing cost is reading the plan before adding the do label — which is the human work we wanted to preserve anyway.

Related Posts

Try Storyie

If you want to see the product that came out of this workflow, storyie.com is the web app and the iOS app is on the App Store. A meaningful portion of the codebase was planned and implemented by Claude via exactly this loop.