← all experiments

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

Architecture diagram showing seven source platforms feeding through an extractor layer into a canonical data model, then through a transformer pipeline into the Smartsheet loader
Seven extractors, one canonical model, one loader. The transformer sits between extraction and loading — it's where platform-specific types get normalised into Smartsheet column types.

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.

PlatformThe gotchaHow it was handled
Monday.comHTTP 200 on batch errorsParse response body for errors, not just status
NotionFile URLs expire in 1 hourRe-download and re-upload attachments at migration time
JiraRich text is Atlassian Document FormatRecursive JSON tree walk to extract plain text
AirtableAttachment URLs expire in 2 hoursSame as Notion — immediate re-upload
Notion3 req/sec rate limit, aggressively enforcedToken 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.