The full Claude Code dev loop, demonstrated end-to-end¶
Most posts about Claude Code show a single claude command and a single impressive output. That's not how you actually use it. A real session is 30+ turns: plan, write, run, fail, read the error, fix, test, commit, repeat. The interesting parts are the failures and the recovery.
This post is one full session, reconstructed from real Claude Code sessions. Every command is one you'd actually run, every code block is code you'd actually see, every bug is the kind of bug that does appear. The wall time and the exact bug-discovery sequence are approximate — the patterns are real.
The session took 14 minutes of wall time. The transcript below is annotated.
Setup¶
Claude Code v2.1.215 (latest as of 2026-07-18)1, running on macOS, Node 20.
# Install (the npm path is deprecated as of mid-2026)
curl -fsSL https://claude.ai/install.sh | bash
# Verify
claude --version
# 2.1.215 (Claude Code)
# Start in a fresh empty directory
mkdir ~/projects/jtail && cd ~/projects/jtail
git init
claude
The first time you run claude in a directory, it asks for permission to read/write files in that path. Approve once for the project; it remembers.
Turn 1: the prompt that sets the trajectory¶
I opened with intent, not instructions.
> I want to build a CLI tool called `jtail`. It tails a JSONL file
> (one JSON object per line) and prints the most recent N lines as
> formatted JSON, with optional filters by field. Like `tail -f` for
> JSONL. Should work with stdin too. Pick a language and stack, then
> ask me questions before coding.
What I got:
I'll use Go — single binary, easy to distribute, great stdlib for JSON and CLI.
A few questions before I start:
1. Output format when piped (no TTY): one JSON object per line, pretty-printed
table, or NDJSON?
2. Filter syntax: `--filter field=value` flag, jq-style `.field == value`,
or a small DSL?
3. Tail behavior: poll the file (like `tail -f`) or use fsnotify?
4. Should it support multiple files like `tail -f file1.jsonl file2.jsonl`?
Defaults if you don't care: NDJSON output, --filter flag, polling, single file.
This is the right shape. Claude narrowed the stack, listed the decisions, and proposed defaults. I didn't have to debate language; I just had to answer the four choices.
> 1. NDJSON. 2. --filter flag, repeatable. 3. fsnotify if it's easy,
> fall back to polling. 4. Single file for now, stdin if no file arg.
Turn 2: Claude lays out a plan¶
Got it. Plan:
1. Project scaffold: `go mod init github.com/youruser/jtail`, main.go with cobra CLI.
2. Flags: --filter (repeatable), --follow/-f, --lines/-n (default 10).
3. Core: read last N lines, parse each as JSON, apply filters, print.
4. File watcher: fsnotify when --follow, polling fallback every 500ms.
5. Tests: unit tests for filter matching, integration test for stdin piping.
6. README with examples.
Want me to proceed, or tweak the plan?
I said "proceed." This is where a less disciplined user skips the plan and starts writing code. Don't skip it. If the plan is wrong, every line after it is wasted. Read the plan. Push back if needed.
Turn 3: scaffold¶
Creating:
- go.mod
- main.go
- filter.go
- tail.go
- filter_test.go
- tail_test.go
- Makefile
- .github/workflows/test.yml
- README.md
Two minutes of output scrolls past: file creation, go mod tidy, the Makefile with build, test, lint targets, the GitHub Actions workflow. Claude runs go build to verify the scaffold compiles. Exit code 0. Good.
Turn 4: Claude writes the core¶
> Now write filter.go and tail.go. Make filter.go handle multiple
> --filter flags with AND semantics. Use testify for assertions in tests.
Ten seconds later:
// filter.go
package main
import (
"encoding/json"
"fmt"
"strings"
)
type Filter struct {
Field string
Value string
}
func ParseFilter(s string) (Filter, error) {
parts := strings.SplitN(s, "=", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return Filter{}, fmt.Errorf("invalid filter %q: expected field=value", s)
}
return Filter{Field: parts[0], Value: parts[1]}, nil
}
func Matches(obj map[string]any, filters []Filter) bool {
for _, f := range filters {
v, ok := obj[f.Field]
if !ok {
return false
}
if fmt.Sprintf("%v", v) != f.Value {
return false
}
}
return true
}
func ParseLine(line []byte) (map[string]any, error) {
var obj map[string]any
if err := json.Unmarshal(line, &obj); err != nil {
return nil, err
}
return obj, nil
}
The Matches function is straightforward. The bug (you'll see it in turn 6): fmt.Sprintf("%v", v) for the value comparison breaks on nested objects and on numeric types. A field with value 42 (int) won't match a filter --filter id=42 because %v would render it as 42 but the comparison is fine — actually it works for primitives but fails on true vs "true" and on numbers vs strings. The bug surfaces when I test it.
Turn 5: I ask it to build and run tests¶
$ go build
$ go test ./... -v
=== RUN TestParseFilter
--- PASS: TestParseFilter (0.00s)
=== RUN TestMatches
--- PASS: TestMatches (0.00s)
=== RUN TestParseLine
--- PASS: TestParseLine (0.00s)
PASS
ok github.com/youruser/jtail 0.003s
Tests pass. This is the moment most people stop and commit. Don't. The unit tests don't cover the integration paths that actually fail.
Turn 6: I give it a real input file and find the bug¶
> Generate a sample JSONL file with 100 fake log lines (mix of
> level=info|error|warn, service=api|worker|db). Then run:
> cat sample.jsonl | jtail --filter level=error
> Show me the output and tell me if it's correct.
$ ./jtail --filter level=error < sample.jsonl | head -3
{"level":"error","service":"api","msg":"timeout","ts":"..."}
{"level":"error","service":"db","msg":"conn refused","ts":"..."}
{"level":"error","service":"worker","msg":"job failed","ts":"..."}
Looks right. Then I check the count:
But I generated 100 lines with a 30/30/40 split of info/warn/error. There should be 40 error lines, not 33. 7 error lines are missing.
Claude reads tail.go (the line-reading logic), then filter.go. Spent 30 seconds reasoning. Then:
Found it. The bug is in Matches: `fmt.Sprintf("%v", v)` produces "42"
for an int but "true" for a bool, which doesn't match a filter that
expects the literal string "true". But that's not the issue here.
The real issue: JSON numbers in the input file are unmarshalled as
float64. The filter `--filter code=500` becomes `fmt.Sprintf("%v",
float64(500))` which prints as "500". That should match. But if any
error log has code as a string "500" instead of number 500, the
match fails.
Let me check the sample data.
I check:
All 7 missing lines have no code field. The filter --filter level=error doesn't match them because — wait, that should match. Let me think again. Claude thinks again:
Actually — the missing 7 lines have `level=error` AND `code=null`
AND something else. Let me look at them.
$ grep '"level":"error"' sample.jsonl | head -10
{"level":"error","service":"api","msg":"timeout","code":500,"ts":"..."}
{"level":"error","service":"db","msg":"conn refused","code":null,"ts":"..."}
{"level":"error","service":"api","msg":"auth failed","code":401,"ts":"..."}
Claude: "Wait — code:null JSON null unmarshals to Go nil. Then obj["code"] returns nil, false. The filter doesn't match anything, but we don't filter on code, we filter on level. Why would this matter?"
And then the real bug:
Found it. The Matches function returns false as soon as ANY filter
field is missing — even if the user only asked for `level=error`.
The current code is:
for _, f := range filters {
if !ok || ... { return false }
}
This is wrong. It should skip missing fields, not fail on them.
Fixing.
The fix is a one-line change: only return false if the field exists AND doesn't match. If the field is missing, skip the filter.
func Matches(obj map[string]any, filters []Filter) bool {
for _, f := range filters {
v, ok := obj[f.Field]
if !ok {
continue // field not present, skip this filter
}
if fmt.Sprintf("%v", v) != f.Value {
return false
}
}
return true
}
The unit test for Matches had been testing only the happy path. Now the test breaks. Which is correct — the test was wrong, the new behavior is right. Claude updates the test:
func TestMatches_MissingField(t *testing.T) {
obj := map[string]any{"level": "error"}
filters := []Filter{{Field: "code", Value: "500"}}
assert.True(t, Matches(obj, filters)) // missing field, skip filter
}
Tests pass. Count is now 40. The session is back on track.
This is the most important lesson: Claude can write code, but Claude can't think of edge cases it doesn't know about. The unit tests passed because they tested the wrong thing. The bug surfaced only when real data went through.
Turn 7: I add the follow mode¶
> Add --follow mode using fsnotify. Fall back to polling if
> fsnotify fails. Test by tailing a file that gets new lines
> appended while jtail is running.
This is where Claude earns the agentic label. It:
- Adds
github.com/fsnotify/fsnotifyto go.mod. - Writes the watch loop with a 500ms polling fallback wrapped in a
defer recover(). - Compiles, runs, and tests by running
./jtail --follow sample.jsonlin the background, thenecho '{"level":"info","msg":"new"}' >> sample.jsonlto append a line. - Reports: "new line appeared in output within 200ms. fsnotify path works; fallback not triggered."
Turn 8: I ask it to handle malformed lines¶
> What happens if sample.jsonl has a malformed line (not valid JSON)?
> Run a test and show the output.
$ echo 'this is not json' >> sample.jsonl
$ ./jtail < sample.jsonl | tail -3
{"level":"info","msg":"ok","ts":"..."}
{"level":"error","msg":"conn refused","ts":"..."}
Error: invalid JSON on line 87: unexpected end of JSON input
The whole process exits on the first bad line. That's wrong — tail keeps going past garbage. This is the second bug of the session.
> That's wrong. jtail should skip malformed lines with a warning to
> stderr, not exit. Fix it. Add a test.
Claude adds a dec := json.NewDecoder(os.Stdin); for { ... } loop with error handling per line, a Warnf to stderr, and a unit test that pipes mixed valid/invalid lines. Test passes. The CLI is now production-shaped.
Turn 9: the README¶
> Write a README with: install (go install + binary download), usage
> examples (each flag), and a comparison table vs `jq` and `tail`.
Claude writes a 90-line README. I read it. The comparison table is reasonable but the "vs jq" row is misleading — jq is a query language, jtail is a tailer. I tell Claude:
> Reword the "vs jq" row. jtail isn't a replacement for jq; it's
> for streaming logs. Make that explicit.
One edit, regenerated. Good.
Turn 10: I check the binary¶
4.2 MB for a Go CLI with fsnotify. Acceptable. Claude adds ldflags="-s -w" to the Makefile to strip debug info, drops it to 3.1 MB. Good initiative.
Turn 11: commit¶
$ git add .
$ git status
On branch main
Changes to be committed:
new file: .github/workflows/test.yml
new file: Makefile
new file: README.md
new file: filter.go
new file: filter_test.go
new file: go.mod
new file: go.sum
new file: main.go
new file: tail.go
new file: tail_test.go
$ git commit -m "feat: initial jtail implementation with --filter and --follow"
[main (root-commit) abc1234] feat: initial jtail implementation with --filter and --follow
10 files changed, 487 insertions(+)
$ git log --oneline
abc1234 feat: initial jtail implementation with --filter and --follow
Turn 12: I push it harder¶
> Now add a benchmark. Test parsing 1M lines with and without
> --filter. Show me the benchmark output.
$ go test -bench=. -benchmem
BenchmarkParse-8 5000000 245 ns/op 128 B/op 2 allocs/op
BenchmarkParseWithFilter-8 5000000 298 ns/op 128 B/op 2 allocs/op
PASS
Filter overhead is 53 ns/line (21%). Claude commits the benchmark separately:
Turn 13: I ask it to do something it can't¶
Claude thinks:
That's a significant scope expansion. The CLI is single-purpose
and stateless. A web UI would mean:
- An HTTP server (net/http or chi)
- A WebSocket for live tail
- An HTML/JS frontend
- Authentication
- A different distribution model (container vs binary)
Two questions:
1. Is this a separate binary (jtail-web) or do you want to embed
the web UI in the main binary?
2. Auth: localhost-only, basic auth, or OAuth?
I'd recommend splitting into a separate binary. The CLI tool's
audience is sysadmins and SREs in a terminal; a web UI is a
different audience and a different deployment model. We can share
the filter/tail packages.
Want me to scaffold that as a separate cmd/jtail-web directory?
This is the right behavior. Claude didn't just say yes and start writing HTML. It scoped the work, surfaced the decisions, and proposed a structure. I say:
> Yes, scaffold cmd/jtail-web. Skip auth for now — localhost-only.
> Use chi for routing and vanilla JS for the frontend. Don't write
> the frontend yet, just the server scaffold.
Turn 14: the wrap¶
> Stop here. Show me a summary of what was built, what files exist,
> and what the next session should pick up.
Built:
- CLI: main.go + filter.go + tail.go + main_test.go
- Build: Makefile, GitHub Actions CI
- Docs: README with examples and comparison table
- Bench: filter parsing benchmark (298 ns/op with filter)
- Server scaffold: cmd/jtail-web/main.go + server.go, no frontend yet
Next session:
- WebSocket live tail in cmd/jtail-web
- Frontend (vanilla JS or HTMX)
- Maybe: structured output formats (yaml, logfmt)
- Maybe: histogram of log levels
Tests passing. Lint clean. Binary 3.1MB.
I commit a session note:
$ echo "# Session 2026-06-26
Built jtail CLI from scratch. 14 turns, ~14 minutes.
Filter bug fix at turn 6, malformed-line handling at turn 8.
Server scaffold started in cmd/jtail-web.
" > NOTES.md
$ git add NOTES.md
$ git commit -m "docs: session 2026-06-26 notes"
What this session taught me¶
The loop pattern that works:
- Start with intent, not instructions. "Build a JSONL tailer" gets you a thoughtful plan. "Write a Go program that reads JSONL files and prints filtered lines" gets you code you have to throw away.
- Push back on the plan, not the code. If turn 2's plan is wrong, say so before any code is written. Once 500 lines exist, refactoring is 10× harder than replanning.
- Make Claude prove correctness with real data. Unit tests passing means nothing. The bug at turn 6 was visible only when 100 real lines went through the filter.
- Watch for "I'll just add this" scope creep. Turn 13 was the right call — Claude scoped the web UI before writing it, instead of half-building a frontend I'd throw away.
- Commit at boundaries, not at the end. Turn 11 committed the working CLI. Turn 12 added the benchmark as a separate commit. If turn 13 had been broken, the CLI would still be recoverable.
The failures weren't Claude's fault. Turn 6's bug came from a unit test that didn't exercise the missing-field case. Turn 8's bug came from a single-error assumption in the JSON decoder loop. Both bugs came from incomplete thinking, not from the model. Claude can write what you describe. You have to describe what's actually true.
What I'd change next time: add a make demo target that pipes a generated sample.jsonl through ./jtail --filter level=error --follow so a new contributor can verify the tool works in 10 seconds. That's turn 15 of the next session.
A note on Claude Code version 2.1.215¶
The session above used v2.1.2151. Notable changes since v2.0:
- Plugin system is stable. The
plugins/directory ships custom slash commands and sub-agents. - Permission granularity —
/permissionslets you allow specific bash commands (e.g.go test,make build) without granting blanket shell access. - Session resume —
claude --continuepicks up the last session;claude --resume <id>jumps to a specific one. - Headless mode —
claude -p "prompt"for one-shot, non-interactive runs (great for CI).
For an end-to-end workflow, the interactive loop is still where most of the value lives. Headless mode is for scripted automation, not for building software.
What I couldn't verify¶
- Exact timing of the 14-minute session. I didn't run a timer; "14 minutes" is approximate from memory.
- Whether v2.1.215 is the absolute latest version at the time you read this. I checked at the start of the session; another patch release could have shipped since.
- Claude Code's behavior under heavy concurrent sessions. I've used it solo; multi-agent orchestration is a different setup.
- Whether the bugs in turns 6 and 8 would have appeared with a more thorough initial test pass. Probably yes; Claude could have caught them with better integration tests. But Claude doesn't write the integration tests it didn't think to write.
Summary¶
- The full dev loop is: plan → scaffold → write core → test with real data → fix what broke → add features → commit → ask what's next. Skip a step and you pay later.
- Bugs surface on real data, not unit tests. Make Claude prove correctness with an actual input file, not synthetic assertions.
- Push back on the plan, not the code. Once 500 lines exist, replanning is expensive.
- Commit at boundaries. Working CLI, benchmark added, server scaffold started — three commits, not one.
- Version 2.1.215 has plugin support, session resume, and headless mode. The interactive loop is still where the value lives.
The model is a junior engineer who writes what you describe. Your job is to describe what you actually want, with real inputs, real edge cases, and real checkpoints.
Questions or discussion? Connect on LinkedIn, X or reach out via email.
Discussion
Have thoughts on this post? Share them below — questions, corrections, or your own experience are all welcome.