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
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 '')
")
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
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
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"
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 → Settings → Secrets and variables → Actions 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:
- List tasks (with some priority or ordering)
- 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."
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)