I made this post and a lot of people wanted to know more, so here we are - a little tour of claude code internals so you can make better use of the todo list feature.
Installing claude code
To try this out, you'll need claude code:
npm install -g @anthropic-ai/claude-code
There are a few other setup steps that I'll skip over here, but help yourself to the very comprehensive docs if needed.
Running claude code in headless mode
Claude code has a special -p
flag for running in headless mode, you can try it out:
claude -p "write me a haiku about programming"
You should see a short response like:
Code flows like water,
Bugs hide in silent corners--
Compile, debug, breathe
One thing that requires a bit more finagling is if you want Claude to do things that normally require approvals, like writing or editing files.
claude -p \
"write me a haiku about programming into the file at ./haiku.txt" \
--allowedTools="Write,Edit"
You can try this without the --allowedTools
flag and Claude will output some message about how it doesn't have permission.
Streaming json output
You can run Claude Code with some extra flags to see every event:
claude -p "write me a haiku about programming in ./haiku.txt" \
--allowedTools="Write,Edit" \
--output-format=stream-json \
--verbose
Now we're getting into the internals - among others, you should see a couple lines like this
{"type":"assistant","message":{"id":"msg_01Aj2DzG8ZmzJbLwH848x2Sc","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"tool_use","id":"toolu_01DmJKv4gRW2TqAywfaXa7f1","name":"Write","input":{"file_path":"/Users/dex/go/src/github.com/dexhorthy/tmp/haiku.txt","content":"Code flows like water\nDebugging through the long night\nElegant solution"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":24036,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"e2393023-f234-46fc-a341-693936cbcdb8"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DmJKv4gRW2TqAywfaXa7f1","type":"tool_result","content":"File created successfully at: /Users/dex/go/src/github.com/dexhorthy/tmp/haiku.txt"}]},"parent_tool_use_id":null,"session_id":"e2393023-f234-46fc-a341-693936cbcdb8"}
{"type":"assistant","message":{"id":"msg_015yENms1FYjZbJXwTUHXj1d","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Done."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":331,"cache_read_input_tokens":24036,"output_tokens":5,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"e2393023-f234-46fc-a341-693936cbcdb8"}
These are pretty verbose, so let's try again, piping to jq
to only show the message. We'll also remove the haiku file we created previously.
rm haiku.txt
claude -p "write me a haiku about programming in ./haiku.txt" \
--allowedTools="Write,Edit" \
--output-format=stream-json \
--verbose \
| jq -r '.message.content[]'
jq
will print a few errors on the messages that don't contain .message.content
, that's fine - we should see something a lot more readable:
jq: error (at <stdin>:1): Cannot iterate over null (null)
{
"type": "text",
"text": "I'll write a haiku about programming and save it to ./haiku.txt."
}
{
"type": "tool_use",
"id": "toolu_01TyZn4YQk99Dp2qjEdNsG7s",
"name": "Write",
"input": {
"file_path": "/Users/dex/go/src/github.com/dexhorthy/tmp/haiku.txt",
"content": "Code flows like water\nBugs emerge from empty lines\nCoffee fuels the fix"
}
}
{
"tool_use_id": "toolu_01TyZn4YQk99Dp2qjEdNsG7s",
"type": "tool_result",
"content": "File created successfully at: /Users/dex/go/src/github.com/dexhorthy/tmp/haiku.txt"
}
{
"type": "text",
"text": "Done. Your programming haiku is in haiku.txt."
}
jq: error (at <stdin>:1): Cannot iterate over null (null)
Tracking Todos
One of the most powerful features of claude code is the ability to track todos. This is a simple system with a single TodoWrite
tool call that enables the model to update a list of todo-items. This is an expression of one of the earliest "agentic" patterns, a simple chaining of two LLM calls, one to generate a plan, and another to execute it.
Claude may by default create a todo list for more complex tasks, but you can also prompt it to do so even for simple ones. Let's try that now. We'll use tee
to save the output to a .jsonl
file (the json lines format) so we can explore the events without having to re-run the command.
rm haiku.txt
claude -p '
write me a haiku about programming in ./haiku.txt,
then write me a sonnet about programming in ./sonnet.txt,
then review both files and write me a review in review.txt
MANDATORY - always maintain a detailed todo list!
' \
--allowedTools="Write,Edit,TodoWrite" \
--output-format=stream-json \
--verbose \
| tee claude_output.jsonl
We can start inspecting this claude_output.jsonl file to see the TodoWrite calls
cat claude_output.jsonl | grep TodoWrite | jq -r '.message.content[]'
And you should see a stream of calls as the model progresses through its todo list - for example, here's one mid-way through the workflow, that shows the haiku.txt
task as completed
and the sonnet.txt
task as in_progress
. You can try the same prompt in interactive claude and you would see something similar (although, as noted in the tweet above, priorities are not shown in the claude interactive UI!)
{
"type": "tool_use",
"id": "toolu_011DtSnJEBAk4m6AV1AEVS4C",
"name": "TodoWrite",
"input": {
"todos": [
{
"id": "1",
"content": "Write haiku about programming in ./haiku.txt",
"status": "completed",
"priority": "high"
},
{
"id": "2",
"content": "Write sonnet about programming in ./sonnet.txt",
"status": "in_progress",
"priority": "high"
},
{
"id": "3",
"content": "Review both poetry files",
"status": "pending",
"priority": "medium"
},
{
"id": "4",
"content": "Write review in ./review.txt",
"status": "pending",
"priority": "medium"
}
]
}
}
Custom visualization
Okay so now we have json for the TodoWrite calls streaming, how can we visualize it? I'll leave the bulk of this as an exercise for you, the reader. Essentially, just like we used jq
command to clean up the output, you can build a script in any language you prefer to read jsonl from stdin and render something pretty. Here’s an example from an OmniFocus export tool I was working on this weekend:
The script that renders this particular input is available in the dexhorthy/multiclaude repo, where I keep a small grab bag of hacky utilities for working with claude code. But it's quite easy to instruct claude (interactive mode this time) to build you a script.
claude 'read claude_output.jsonl and write a script in typescript that reads the jsonl on stdin and renders a concise visualization of the jsonl messages.
test it with
tailf -n 5 claude_output.jsonl | bun ./visualize.ts
'
When it's done, you can test this yourself by piping your stashed jsonl output to the script:
tailf -n 5 claude_output.jsonl | bun ./visualize.ts
You can of course use any language you like, but I find bun+typescript to work quite well out of the box for quick scripts like this.
Aside: The 20-item todo list
One of the questions I got a few times was "how do you get Claude to create such a long todo list?"
The TodoWrite
tool is a tool like any other, which means you can get the model to call it more often and/or in a specific way by prompting it. I came across a good CLAUDE.md (shouts out to @nisten) that instructs Claude to always maintain a todo list of at least 20 items.
Aside: Granting Permissions
When running in headless mode, Claude has no interactive interface to ask you for permission to do things like edit files, run bash commands, or read additional directories. You can grant Claude permission to do these things by using the --allowedTools
flag. You can also store this in a .claude/settings.local.json
for your project. Here's an example config:
{
"permissions": {
"allow": [
"mcp__exa__web_search_exa",
"Bash(rg:*)",
"Bash(find:*)",
"Write",
"Edit",
"Read",
"WebSearch",
]
}
}
SECURITY NOTE - Claude doesn't get access to do things by default because they might be dangerous. If you give Claude broad access to stuff, it might do something you don't like. As noted in the docs: PLEASE BE CAREFUL. I am by no means advising you to open up broad permissions!
If claude -p
can't execute a thing, it might try some alternatives, but eventually it will bomb out with a message that it didn't have access to the tools it needed.
ADVANCED -- you can also pass a --permission-prompt-tool
which is a pointer to an MCP server that implements a tool that can be used to request permissions from a human in an arbitrary way. HumanLayer implements a permission-prompt-tool MCP server that can be used to fetch these approvals via slack, email, or a web UI.
If you wanna try this, check out the MCP Config and Example Script.
Conclusion
Claude code is more than just an interactive CLI - it exposes a powerful SDK that can be customized to build your own tools and workflows on top of the rock-solid agent loop and toolset of claude code.
We glossed over a lot of topics here, like MCP, permissions, etc. Got a question or wanna chat more? Built something cool? Ping me at https://x.com/dexhorthy and let's riff!