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
planlabel 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, runstype-checkandlint, and opens a PR. - Mention
@claudeanywhere 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:
- Requirement alignment — Claude's reading of the Issue is visible before it acts on that reading.
- Blast radius preview — the plan names which files and packages will be touched. In a monorepo this matters: a change to
packages/lexical-commonthat adds a"use client"directive would break Expo. - 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.ymlTriggers
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.MultiEditin particular speeds up large refactors that touch many files.Bash(pnpm:*)— runstype-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 packagesFor 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: 0Without 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:
- Write an Issue describing a feature or bug. No special format required — write it the way you would for a human reviewer.
- Add the
planlabel. 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. - Read the plan. If the direction is wrong, reply with a correction and it will respond interactively. If it looks good, continue.
- Add the
dolabel. Claude creates a branch, implements the plan, runspnpm type-check && pnpm lint, and opens a PR withCloses #<number>in the description. - Review the PR normally. Use
@claudein 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
- Building a Monorepo with pnpm and TypeScript — workspace layout and the cross-package rules we enforce in CLAUDE.md
- Cross-platform Lexical with
use dom: monorepo gains and the bridges you still own — the platform-boundary constraints that make CLAUDE.md guidance necessary - AI-driven development with SpecKit — how we think about the broader human-in-the-loop AI workflow
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.