Seven APIs, One Binary
What work is left for a PM when an AI handles the implementation?
I built a production CLI tool in Go that migrates data from seven platforms into Smartsheet. It handles rate limits, resumable state, attachment re-upload, rich text stripping, contact column formatting, and a full interactive CLI journey. I am not a Go engineer. I had never written a Go package before this.
The interesting part isn’t that the AI wrote the code. It’s what was left for me to do once it did.
The problem
Smartsheet doesn’t have a migration story. If you’re moving from Asana or Jira or Airtable, you’re doing it manually, row by row, or you’re paying for an integration tool that doesn’t handle edge cases. The real edge cases — what happens when an attachment URL expires in two minutes, what happens when Monday.com returns HTTP 200 for an error, what a CONTACT_LIST column actually expects — aren’t documented anywhere useful. You find them by running into them.
I wanted a single binary. Run it, pick your source, enter your credentials, select your projects, confirm, watch it go. No config files, no YAML, no hosted service with access to your tokens.
The decisions that shaped everything
Three constraints locked in before a line of code was written, and each one had teeth.
Three constraints, in order of impact
1. Non-destructive — The tool never writes to the source. Resumability has to be tracked entirely on the destination side via a local state file. The constraint created the architecture.
2. Resumable — Migrations fail partway through. The state file records completed sheet IDs; re-running skips them. A 500-row sheet that dies at row 340 doesn’t restart.
3. Full fidelity — Every platform has its own type system. Getting full fidelity meant understanding each platform’s actual behaviour, not its documented behaviour. That gap was most of the research.
What the system looks like
The canonical model is the load-bearing piece. Every extractor produces the same types. The Smartsheet loader never knows which platform it came from.
That separation meant each extractor could be built and tested in isolation, and the loader could be built once. The transformer handles the type mapping: Asana’s date fields become Smartsheet DATE columns, Monday’s status columns become PICKLIST, Notion’s people fields become CONTACT_LIST. The mapping is one-way and lossy in specific documented ways — Smartsheet doesn’t support DATETIME as a column type, so those fall back to DATE. Every lossy conversion is a deliberate decision, not an accident.
What I actually did
The code was Claude Code’s. The decisions were mine.
That sounds cleaner than it was. In practice it meant writing detailed specs before each implementation chunk, then reading the output carefully enough to catch when the implementation had made a different decision than I intended. The spec for the CLI journey — the mapping preview, the per-project live status lines, the post-migration menu — was three pages of annotated wireframes in a markdown file before a single line of run.go changed.
The API research was the most time-intensive part. For each platform I needed to know: what does the auth flow look like, what are the rate limits, what do the actual response shapes look like for edge cases, what fails silently.
| Platform | The gotcha | How it was handled |
|---|---|---|
| Monday.com | HTTP 200 on batch errors | Parse response body for errors, not just status |
| Notion | File URLs expire in 1 hour | Re-download and re-upload attachments at migration time |
| Jira | Rich text is Atlassian Document Format | Recursive JSON tree walk to extract plain text |
| Airtable | Attachment URLs expire in 2 hours | Same as Notion — immediate re-upload |
| Notion | 3 req/sec rate limit, aggressively enforced | Token bucket, not sleep |
That research lives in the codebase as comments and in the API gotchas list that seeded each extractor’s design. The code that handles it is Go. The decision to handle it at all was mine.
The conflict handling — --conflict=skip|rename|overwrite — came from thinking about what actually goes wrong when someone runs a migration twice. Skip is safe. Rename is what you want when you’re doing a test run before the real one. Overwrite is destructive and useful when you know you want to replace what’s there. Those three modes cover the real use cases. A fourth mode (merge, updating existing rows) would have been more powerful and much more likely to produce corrupted data. It’s not in the tool.
What I now know
Product thinking transfers directly to engineering problems. The questions are the same ones — what are the real failure modes, what does the user actually need vs what they asked for, what is the minimum surface area that covers the real use cases. The implementation substrate changed. The thinking didn’t.
Product thinking transfers directly to engineering problems. The questions are identical. The implementation substrate changed. The thinking didn’t.
What doesn’t transfer automatically is calibration. Early on I was under-speccing and over-trusting — writing vague requirements and assuming the output would be reasonable. It often was. When it wasn’t, the failure mode was subtle: code that worked but had made a different tradeoff than I intended, or that handled a happy path correctly and an edge case wrong in a way that wouldn’t surface until a real migration. Tighter specs produced better code, the same way tighter briefs produce better design work.
The seams show in the places where the implementation complexity outran the spec. The runFailedProjects function in run.go is longer than it should be because I specified the behaviour without fully specifying the data flow. It works. It’s not clean. On a team with an engineer, that’s the kind of thing that gets caught in review. Working this way, it stays.
That’s the honest version of it. Not “AI replaces engineers.” More like: a PM who specs carefully and reads code critically can produce working software without writing it. The ceiling is real — it’s proportional to how precisely you can specify what you want. But the ceiling is higher than I expected.