Building listentotaxman-cli with OpenCode + Claude Sonnet 4.5

05 Jan 2026 in Tech

If you've ever needed to calculate tax in the UK, you've probably landed on https://listentotaxman.com/. My main use case is comparing two sets of numbers - sometimes it's base salary, sometimes it's to see what happens if I change a pension contribution %.

The website is ok, but I wanted something I could script. So this morning, I wrapped their API in a CLI. Or rather, I asked Copilot / Claude Sonnet 4.5 to wrap their API while I did some other work.

This idea has been on my /ideas page since September 2021, and I've never taken the time to build it out. Occasionally I'll use the website and remember that I wanted to build a CLI but then I solve my immediate problem and move on.

Today, I decided that this was a nice, bounded problem that I could use to test out OpenCode, which was recommended to me over the weekend as a vendor neutral Claude Code replacement.

Building with OpenCode + Copilot

Here's the transcript of the conversation I had with OpenCode. I've skipped over some of the clarifying questions that were inconsequential (e.g. when it asked if responses should be cached, which ASCII table library I prefer etc). I've added a clarification section when it asked good questions, and insight for when I was pleasantly surprised when the LLM reminded me about actions that I needed to take.

As with when I was building gh-saved-issues I did some initial exploration so that I could provide curl commands to get the project started:

πŸ“ Prompt

You are building a Cobra based Golang CLI for https://listentotaxman.com/. A sample curl request for calling the API is:

bash
curl 'https://listentotaxman.com/ws/tax/index.js.php' \
--data-raw '{"response":"json","year":"2025","taxregion":"uk","age":"0","pension":"3%","time":"1","grosswage":100000}'

Build a CLI that can be invoked as:

bash
listentotaxman check --year 2025 --region uk --age 0 --pension 3% --grosswage 100000 --student-loan postgraduate --extra 999 --tax-code K12

The --tax-code flag should be passed as "taxcode" in the API request
The --student-loan flag should be passed as "plan" in the API request
The --region flag should be passed as "taxregion" in the API request

Year should default to the current year (e.g. 2026) if today's date is after the fifth of april. Otherwise it should default to the previous year.

--tax-region should default to "uk"

The LLM one-shot this pretty well. I could run listentotaxman check and see the output:

╔══════════════════════════════════════════════╗ β•‘ Tax Calculation for 2025 (UK) - Yearly β•‘ ╠══════════════════════════════════════════════╣ β•‘ Gross Salary Β£100,000.00 β•‘ β•‘ Taxable Pay Β£97,129.00 β•‘ β•‘ Tax Paid Β£31,311.60 β•‘ β•‘ National Insurance Β£4,010.60 β•‘ β•‘ Student Loan Β£4,740.00 β•‘ β•‘ Pension (You) Β£3,000.00 β•‘ β•‘ Net Pay Β£56,937.80 β•‘ ╠══════════════════════════════════════════════╣ β•‘ Employer's NI Β£14,250.00 β•‘ β•‘ Pension (HMRC) Β£2,000.00 β•‘ β•‘ Total Cost Β£116,250.00 β•‘ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•

One of the things I liked about the website is the ability to see these figures for a specific time period:

πŸ“ Prompt

Add a --period flag that defaults to "yearly". Other possible options are "monthly" "weekly" "daily" "hourly". Take the "yearly" values and divide by the 12 (monthly), 52 (weekly) or 365 (daily) or 2920 (hourly)

Did you spot my mistake? The LLM did:

❓ Clarification

Question: Is 2920 correct for hourly? This assumes 8-hour workdays. Some alternatives:

2080 hours (40 hours/week Γ— 52 weeks) - standard US calculation
1820 hours (typical UK full-time: 37.5 hours/week Γ— 52 weeks - 5 weeks holiday)
2920 hours (365 Γ— 8 hours) - your specified value

I opted to use 2080 hours, assuming that I get paid for my lunchtime too.

At this point the CLI was working well, but there were some formatting issues due to the recent changes:

πŸ“ Prompt

Alignment is off in the header:

══════════════════════════════════════════════╗
β•‘ Tax Calculation for 2025 (UK) - Monthlyβ•‘
╠══════════════════════════════════════════════╣

With the basic usage working, it was time to add the compare command that the UI doesn't provide. The trickiest thing to figure out here was how to accept groups of information.

I ended up accepting the pattern --option <NAME> --flags go --here, where any flags provided after one --option value, but before another are treated as one set of configuration:

πŸ“ Prompt

Now add a new listentotaxman compare command. Accept all flags from listentotaxman check

Each flag can be provided multiple times. Each group of inputs should be preceeded by an --option flag e.g.:

bash
listentotaxman compare --option job1 --pension 3% --grosswage 10 --option job2 --grosswage 1000 --pension 10%

Once the command was built, I ran listentotaxman compare --help to see how to use it, but the help screen was useless:

πŸ“ Prompt

Improve the --help output. The following is not useful:

bash
Error: no options specified (use --option to define each scenario)
Usage:
listentotaxman compare
Flags:
-h, --help help for compare
Error: no options specified (use --option to define each scenario)

The LLM updated the --help command with many useful examples:

bash
listentotaxman compare \
--option "Current Job" --income 100000 --pension 3% \
--option "New Offer" --income 120000 --pension 5%

The CLI did everything I needed at this point. Everything that I did next was just polishing the experience:

πŸ“ Prompt

Format numbers using the correct locale e.g. 100,000 rather than 100000

I realsied that I only provided sample API calls, and we should support everything the UI does. I've removed the HTML from the following prompt, but assume that I pasted in the whole HTML form from the UI:

πŸ“ Prompt

Here is the HTML definition for the inputs available on the website. Which CLI flags are we missing?

...snip...

It was interesting for me to see the LLM match the UI properties to the form:

πŸ‘€ Insight

Implemented βœ…
--year - Tax year
--region - Tax region (uk, scotland, wales, ni)
--grosswage - Salary amount
--pension - Pension contribution (Β£ or %)
--student-loan (maps to plan) - Student loan plan
--age - Age selection
--tax-code (maps to code) - Tax code
--extra (maps to add) - Additional allowances/deductions
Missing Flags πŸ”΄
Based on the HTML form, we're missing these inputs:

--married (checkbox: married)

Boolean flag for married status
Affects marriage allowance calculations
--blind (checkbox: blind)

Boolean flag for blind person's allowance
Additional tax-free allowance for registered blind persons
--no-ni or --exempt-ni** (checkbox: exNI)

Boolean flag for "I pay no NI"
For people exempt from National Insurance
--partner-wage (input: partnerGrossWage)

Partner's gross wage for marriage allowance calculations
Only relevant when married flag is set
Affects transferable marriage allowance
--time - Input frequency selector

Currently we only accept yearly amounts
Website supports: year, month, 4 weeks, 2 weeks, week, day, hour
Note: This is an INPUT multiplier, different from our --period OUTPUT divisor
Additional Observations
Student Loan Plans
We support:

βœ… plan1
βœ… plan2
βœ… postgraduate
We're missing:

πŸ”΄ plan4 - Plan 4 (for students from Scotland)
πŸ”΄ scottish - Scottish student loan
Age Values
Current implementation uses string values like "0", "1", "2", "3"

Website uses:

0 - under 65 βœ…
3 - female 60-65 (historical, likely for older tax years) πŸ”΄
1 - 65-74 βœ…
2 - over 75 βœ…
We're mostly correct but missing the "female 60-65" option.

Region Values
Our values match except:

We use: uk, scotland, wales, ni βœ…
Website uses: uk, scotland, eng, wales, ni
Minor difference: We don't have eng (England) as distinct from uk 🟑

I had the LLM implement all of the above flags and options (and add them to --help), then continued to polish the experience:

πŸ“ Prompt

Rename --grosswage to --income

Finally, it was time to release. I knew I wanted to use GitHub Actions and goreleaser:

πŸ“ Prompt

I want to build and release this tool with github actions and goreleaser. Build a plan to add this CI/CD setup

The LLM wanted to do so much more! It suggested enabling CodeQL, dependabot, Homebrew support and Docker builds.

❓ Clarification

Please answer the questions above, and I'll create:

Minimal setup (if you want quick start):

  • .goreleaser.yml with basic config
  • .github/workflows/release.yml for releases
  • .github/workflows/ci.yml for testing

Full-featured setup (if you want everything):

  • All workflows (release, CI, CodeQL, dependabot)
  • Homebrew tap support
  • Docker image builds
  • Comprehensive documentation
  • Security scanning

Which approach do you prefer? And please let me know your answers to the questions above!

I chose an option that it didn't give me: the minimal setup, but with Homebrew/Docker support too.

πŸ“ Prompt

Option 3: Minimal setup, but with Homebrew / Docker support

This is when the LLM surprised me. In addition to generating all of the workflows, it succinctly explained which secrets I needed to add to my repository, and how to find those values:

πŸ‘€ Insight

πŸ”§ Manual Setup Steps Checklist
Before you can release, complete these manual steps:

βœ… Step 1: Create Docker Hub Access Token
Go to https://hub.docker.com/settings/security
Click "New Access Token"
Description: "GoReleaser CI/CD"
Access permissions: "Read, Write, Delete"
Generate and COPY the token (shown only once)

βœ… Step 2: Create GitHub Personal Access Token
Go to https://github.com/settings/tokens
Click "Generate new token (classic)"
Name: "GoReleaser Homebrew Tap"
Expiration: No expiration (or as preferred)
Scopes: Check repo (all sub-scopes)
Generate and COPY the token

βœ… Step 3: Add Secrets to GitHub Repository
Navigate to: https://github.com/mheap/listentotaxman-cli/settings/secrets/actions

Click "New repository secret" for each:

Secret 1: GH_PAT

Name: GH_PAT
Value: (GitHub PAT from Step 2)
Secret 2: DOCKERHUB_USERNAME

Name: DOCKERHUB_USERNAME
Value: mheap
Secret 3: DOCKERHUB_TOKEN

Name: DOCKERHUB_TOKEN
Value: (Docker Hub token from Step 1)

βœ… Step 4: Configure GitHub Actions Permissions
Go to: https://github.com/mheap/listentotaxman-cli/settings/actions
Under "Workflow permissions":
Select "Read and write permissions"
Check "Allow GitHub Actions to create and approve pull requests"
Click "Save"

βœ… Step 5: Verify Homebrew Tap Repository
Confirm https://github.com/mheap/homebrew-tap exists and is:

Public
Empty (or has minimal content - GoReleaser will manage it)

I didn't like that I had to provide permissions via settings. I want them declared in the workflow instead:

πŸ“ Prompt

Update the GitHub Actions workflows to set permissions explicitly

And finally, it wouldn't be a GitHub Actions implementation unless the first build failed. The LLM somehow generated a version 2 configuration file for goreleaser while using version 1 in CI. I pasted the error in and it fixed it with no intervention from me:

πŸ“ Prompt

only configurations files on version: 1 are supported, yours is version: 2, please update your configuration
Error: The process '/opt/hostedtoolcache/goreleaser-action/1.26.2/x64/goreleaser' failed with exit code 1

I pushed these changes to the repo, tagged a new release and watched as the listentotaxman CLI came alive! I ran brew install mheap/tap/listentotaxman on my local machine and now I can compare tax implications as much as I want 😁.