DEV Community

Cover image for I Built a GitHub Action That Codes My Todoist Tasks While I Sleep
Eli_coding
Eli_coding

Posted on

I Built a GitHub Action That Codes My Todoist Tasks While I Sleep

I Built a GitHub Action That Reads My Todoist, Codes the Task, and Marks It Done — Every 5 Hours

Yes, 5 hours. No, it's not random. Keep reading.


The Problem With Being a Solo Developer

You know that feeling when you have a perfectly organized Todoist board, a cup of coffee, and absolutely zero motivation to actually open your code editor?

Same.

I'm a frontend engineer building a product on my own. Between my day job, content creation, and pretending I go to the gym, finding focus time to chip away at the backlog is... a challenge.

So I did what any reasonable developer would do: I automated myself out of the problem.


The Idea: An Agent Queue

I created a Todoist project called Agent_Queue. Every time I have a small coding task — a refactor, a new component, a bug fix — I add it as a task with a description detailed enough for an AI to understand it.

Then a GitHub Action wakes up, grabs the highest priority task, hands it to Claude Code, lets it do its thing, commits the result to my dev branch, and marks the task as complete in Todoist.

I wake up in the morning with code written. Sometimes it's great. Sometimes I need to tweak it. But it's a starting point I didn't have to make myself.


Why Every 5 Hours?

Okay, storytime. 🍿

Claude Code uses API tokens under the hood. Those tokens have usage limits — and if you're running Claude Code autonomously on a task, it can be... enthusiastic. Like, "rewrite-your-entire-codebase-because-why-not" enthusiastic.

Running it every 5 hours gives the token usage time to breathe and reset between runs. It also means if something goes wrong (Claude goes rogue, commits chaos, the API hiccups), you only have one task's worth of damage to deal with — not a pile-up of 47 autonomous commits from overnight.

Think of it as: one task, one session, one breath. The 5-hour gap is the coffee break Claude doesn't know it's taking.

Also, Anthropic's rate limits are real. Respect them. Your wallet will thank you.


The Full Workflow

Here's exactly how it works, step by step:

1. The Trigger

on:
  schedule:
    - cron: '0 */5 * * *'  # every 5 hours
  workflow_dispatch:         # or manually, when you're impatient
Enter fullscreen mode Exit fullscreen mode

workflow_dispatch is your emergency override. Sometimes you fix something in Todoist and just want to run it NOW. I use this constantly.

2. Find the Project + Top Task

The action hits the Todoist API, finds the Agent_Queue project by name, fetches all tasks, and sorts by priority. In Todoist's API, P1 = priority 4 (yes, inverted — don't ask, it's just how it is).

- name: Fetch top priority task from Todoist
  id: todoist
  env:
    TODOIST_API_KEY: ${{ secrets.TODOIST_API_KEY }}
    TODOIST_PROJECT_NAME: 'Agent_Queue'
  run: |
    PROJECTS=$(curl -s \
      -H "Authorization: Bearer $TODOIST_API_KEY" \
      https://api.todoist.com/api/v1/projects)

    PROJECT_ID=$(echo "$PROJECTS" | python3 -c "
    import json, sys, os
    data = json.load(sys.stdin)
    name = os.environ['TODOIST_PROJECT_NAME']
    match = next((p for p in data if p['name'] == name), None)
    print(match['id'] if match else '')
    ")
Enter fullscreen mode Exit fullscreen mode

I use inline Python to parse the JSON — no extra dependencies, no setup. It's a bit cursed but it works beautifully.

3. Run Claude Code

This is the magic step. Claude Code runs in the terminal, reads the full codebase, and executes the task prompt autonomously.

- name: Install Claude Code
  run: npm install -g @anthropic-ai/claude-code

- name: Run Claude Code on task
  env:
    ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
    TASK_PROMPT: ${{ steps.todoist.outputs.task_prompt }}
  run: |
    echo "$TASK_PROMPT" | claude -p \
      --dangerously-skip-permissions \
      --max-turns 10 \
      --output-format stream-json \
      --verbose 2>&1 | tee claude-output.log
Enter fullscreen mode Exit fullscreen mode

A few flags worth calling out:

  • --dangerously-skip-permissions — Claude Code normally asks for confirmation before doing things. In CI there's no one to confirm, so we skip it. Use this with respect.
  • --max-turns 10 — puts a hard limit on how many back-and-forth steps Claude takes. Prevents infinite loops.
  • --output-format stream-json — logs everything as JSON stream so you can actually debug what happened.

4. Commit to Dev

- name: Commit any changes Claude made
  run: |
    git config user.name "Claude Agent"
    git config user.email "claude-agent@users.noreply.github.com"
    git add -A
    if git diff --staged --quiet; then
      echo "No file changes to commit."
    else
      git commit -m "chore: agent task - ${{ steps.todoist.outputs.task_content }}"
      git push
    fi
Enter fullscreen mode Exit fullscreen mode

If Claude made no file changes (maybe the task was already done, or it was a read-only task), the workflow exits cleanly. No empty commits.

5. Mark the Task Done in Todoist

- name: Mark task as complete in Todoist
  if: steps.todoist.outputs.task_found == 'true' && success()
  env:
    TODOIST_API_KEY: ${{ secrets.TODOIST_API_KEY }}
    TASK_ID: ${{ steps.todoist.outputs.task_id }}
  run: |
    curl -s -X POST \
      -H "Authorization: Bearer $TODOIST_API_KEY" \
      -H "Content-Type: application/json" \
      "https://api.todoist.com/api/v1/tasks/$TASK_ID/close"
Enter fullscreen mode Exit fullscreen mode

The success() condition is important — the task only gets marked as done if everything before it succeeded. If Claude crashed, or the push failed, the task stays open and will be picked up again next cycle.


GitHub Secrets You Need

Go to your repo → SettingsSecrets and variablesActions and add:

Secret Where to get it
TODOIST_API_KEY Todoist → Settings → Integrations → Developer
ANTHROPIC_API_KEY console.anthropic.com

Does It Work With Other Task Tools?

Yes — and this is the part I love. The GitHub Actions structure is completely generic. All you need is a task tool with a REST API that can:

  1. List tasks (with some priority or ordering)
  2. Close/complete a task by ID

So you can swap Todoist for:

  • Linear — great for teams, has a beautiful API
  • Jira — enterprise-friendly, more verbose but works
  • Asana — same concept, different endpoints
  • Notion — databases as task lists, totally doable
  • GitHub Issues — meta as it gets, but it works

The only thing you change is the curl commands in the fetch and close steps. Everything else — the Claude Code runner, the git commit, the cron schedule — stays identical.


Tips From Running This for a Few Weeks

Write detailed task descriptions. The task content becomes the prompt for Claude Code. "Fix bug" will get you nowhere. "The login page throws a TypeError when the user submits an empty email — fix the null check in AuthForm.tsx" will get you a working fix.

Use the task description field, not just the title. My workflow uses the description as the full prompt if it exists, falling back to the title. Give Claude context — file names, expected behaviour, what you've already tried.

Start with small tasks. Don't throw "refactor the entire auth module" at it on day one. Start with "add a missing aria-label to the submit button in LoginForm.tsx". Build trust with the agent before giving it the keys to the kingdom.

Review before merging. This commits to dev, not main. Always review what Claude produced before merging. It's usually good, sometimes great, occasionally baffling.


The Vibe

I built this because I believe the best tools are the ones that work while you sleep. Not in a "hustle culture" way — in a "your computer can do repetitive things so your brain doesn't have to" way.

My Todoist board is now also my team standup. I write the task, the agent picks it up, and when I sit down in the morning there's a commit waiting for me to review. It's a weird and delightful workflow and I'm fully here for it.


Full YAML

The complete workflow file is below. Drop it in .github/workflows/todoist-sync.yml and you're good to go.

name: Agent Queue Runner

on:
  schedule:
    - cron: '0 */5 * * *'
  workflow_dispatch:

jobs:
  run-agent-task:
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - name: Checkout repo
        uses: actions/checkout@v4
        with:
          ref: dev

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '24'

      - name: Fetch top priority task from Todoist
        id: todoist
        env:
          TODOIST_API_KEY: ${{ secrets.TODOIST_API_KEY }}
          TODOIST_PROJECT_NAME: 'Agent_Queue'
        run: |
          PROJECTS=$(curl -s \
            -H "Authorization: Bearer $TODOIST_API_KEY" \
            -H "Content-Type: application/json" \
            https://api.todoist.com/api/v1/projects)

          if [ -z "$PROJECTS" ]; then
            echo "Empty response from Todoist API."
            exit 1
          fi

          PROJECT_ID=$(echo "$PROJECTS" | python3 -c "
          import json, sys, os
          data = json.load(sys.stdin)
          projects = data.get('results', data) if isinstance(data, dict) else data
          name = os.environ['TODOIST_PROJECT_NAME']
          match = next((p for p in projects if p['name'] == name), None)
          print(match['id'] if match else '')
          ")

          if [ -z "$PROJECT_ID" ]; then
            echo "Project not found."
            echo "task_found=false" >> $GITHUB_OUTPUT
            exit 0
          fi

          TASKS=$(curl -s \
            -H "Authorization: Bearer $TODOIST_API_KEY" \
            -H "Content-Type: application/json" \
            "https://api.todoist.com/api/v1/tasks?project_id=$PROJECT_ID")

          TASK_JSON=$(echo "$TASKS" | python3 -c "
          import json, sys
          data = json.load(sys.stdin)
          tasks = data.get('results', data) if isinstance(data, dict) else data
          if not tasks:
              print('')
              exit()
          tasks.sort(key=lambda t: t.get('priority', 1), reverse=True)
          t = tasks[0]
          prompt = t.get('description', '').strip() or t['content']
          print(json.dumps({'id': t['id'], 'content': t['content'], 'prompt': prompt}))
          ")

          if [ -z "$TASK_JSON" ]; then
            echo "No tasks found."
            echo "task_found=false" >> $GITHUB_OUTPUT
            exit 0
          fi

          TASK_ID=$(echo "$TASK_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
          TASK_CONTENT=$(echo "$TASK_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin)['content'])")
          TASK_PROMPT=$(echo "$TASK_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin)['prompt'])")

          echo "task_found=true" >> $GITHUB_OUTPUT
          echo "task_id=$TASK_ID" >> $GITHUB_OUTPUT
          echo "task_content<<EOF" >> $GITHUB_OUTPUT
          echo "$TASK_CONTENT" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT
          echo "task_prompt<<EOF" >> $GITHUB_OUTPUT
          echo "$TASK_PROMPT" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT

      - name: Install Claude Code
        if: steps.todoist.outputs.task_found == 'true'
        run: npm install -g @anthropic-ai/claude-code

      - name: Run Claude Code on task
        if: steps.todoist.outputs.task_found == 'true'
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          TASK_PROMPT: ${{ steps.todoist.outputs.task_prompt }}
        run: |
          echo "$TASK_PROMPT" | claude -p \
            --dangerously-skip-permissions \
            --max-turns 10 \
            --output-format stream-json \
            --verbose 2>&1 | tee claude-output.log

      - name: Commit any changes Claude made
        if: steps.todoist.outputs.task_found == 'true'
        run: |
          git config user.name "Claude Agent"
          git config user.email "claude-agent@users.noreply.github.com"
          git add -A
          if git diff --staged --quiet; then
            echo "No changes to commit."
          else
            git commit -m "chore: agent task - ${{ steps.todoist.outputs.task_content }}"
            git push
          fi

      - name: Mark task as complete in Todoist
        if: steps.todoist.outputs.task_found == 'true' && success()
        env:
          TODOIST_API_KEY: ${{ secrets.TODOIST_API_KEY }}
          TASK_ID: ${{ steps.todoist.outputs.task_id }}
        run: |
          curl -s -X POST \
            -H "Authorization: Bearer $TODOIST_API_KEY" \
            -H "Content-Type: application/json" \
            "https://api.todoist.com/api/v1/tasks/$TASK_ID/close"
          echo "Task marked complete."
Enter fullscreen mode Exit fullscreen mode

If you build this or adapt it for Linear/Jira/Notion, let me know in the comments — I'd love to see what variations people come up with.


Follow me on DEV @eli_coding and Instagram @eli_coding for more of this kind of thing.

Top comments (0)