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 |
|
| Output |
|---|---|---|---|---|
plan |
|
| 10 | Issue comment |
do |
|
| 50 | Branch + PR |
interactive |
|
| (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 |
|---|---|
| Pay-per-token, no subscription needed |
| 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.
What a month of agent-written PRs taught us
After about a month, Claude had opened over 13 PRs with a merge rate close to 100% (counting review-requested revisions): UI tweaks, bug fixes, small refactors, a couple of features, most touching one to five files. We have not used the loop for large architecture changes and would not.
- Issue quality is the biggest lever. The agent does what the issue says. Issues that produced clean PRs had a specific scope ("change the icon on the float button from the chevron to a plus"), stated constraints ("do not modify existing tests"), and a verifiable done condition ("
pnpm type-checkpasses"). - The plan step earns its turns. It caught roughly one redirection per five issues, each one a re-run we did not pay for after a PR was open.
- Investigation issues fail. "Figure out why the auth request sometimes fails" produced weak output, because the agent can read code but cannot observe a running system or reproduce device-specific behavior. Those go to an interactive local session instead.
- Undocumented conventions drift. Before
CLAUDE.mdspelled it out, test mocks kept gettingas any, which Biome rejects. The fix was one line of guidance:as unknown as never.
Review shifted accordingly. Formatting and naming are handled by Biome and CLAUDE.md, so we spend review time on business-logic intent (an "update the record's status" issue can mean one record or a whole set), on scope creep when the agent refactors code it noticed nearby, and on whether a technically fine pattern is how this codebase does things.
Related Posts
- Storyie: a diary app that ships on web, iOS, and Android — the product overview and the monorepo layout behind the cross-package rules we enforce in CLAUDE.md
- Lexical EditorState JSON serialization + React Native — the platform-boundary constraints that make CLAUDE.md guidance necessary
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.