15 binaries. 14 MCP tools. Zero vendor SDKs.
Three AI providers, an async video client, and Google Cloud Storage on raw net/http. 1,993 lines, 11 direct deps, 10 MB static binaries. What it cost, what broke, and why SDKs insure the wrong layer.

Fifteen binaries in one repo. Every one of them talks to OpenAI, Gemini on Vertex, Veo, or Google Cloud Storage. Zero vendor AI or cloud SDKs in the module graph. No openai-go. No genai. No cloud.google.com/go/storage. Every provider call is hand-built net/http.
A confession before we start. Until this week I said "14 binaries." My own migration doc says 14. The doc is stale. The fifteenth tool landed on July 4 and the count never caught up. The number that actually is 14: the MCP tools in the server that fronts the fleet. Fourteen mcp.AddTool calls, counted twice this time.
Hold that correction. It is the whole thesis in miniature. The things you assume are pinned drift. The things you buy insurance for barely move.
What this repo is
stark-visual is my personal media toolchain. Image generation and editing across three providers. Veo video behind an async job pool. Animated WebP. Alt text. Brand icons. QR codes. Sprites. Subtitles. Upscaling. Background matting. Lip sync. Fifteen tools in cmd/, each a thin main.go over a package that does the real work.
The numbers: 30,071 lines of Go in about 13 weeks. First commit April 8, latest on main July 4. 18,673 lines of product code, 11,398 lines of tests, a 38% test share. 53 commits on main, squash-merge flow.
It's a playground, not a product. One user: me. Some tradeoffs below only work because of that, and I'll flag exactly which ones.
The default I refused
Wire three AI providers into a Go repo and the reflex is three go get commands. Each SDK drags its own graph behind it: gRPC stacks, protobuf trees, transport abstractions, option builders. And for what? Image generation is one POST per provider. Editing is a second POST with multipart. Veo adds the two job-lifecycle endpoints. That's the entire surface I use.
So the repo has one rule, written into its constitution:
Providers use raw
net/http, no vendor SDKs. API-specific types stay inside provider packages.
(CLAUDE.md, Key Conventions)
The result in go.mod: 11 direct requires. Full module graph, go list -m all, main module included: 80. For comparison, a single cloud SDK often brings more than that on its own.
The mechanism: what "raw" actually means
Not a framework. Not a homegrown SDK. Just endpoints, verbs, and structs with json tags, per provider:
- OpenAI:
POST https://api.openai.com/v1/images/generationsand/v1/images/edits. Bearer token in a header. Edits use stdlibmime/multipart. (provider/openai/openai.go:23-24) - Gemini on Vertex:
{region}-aiplatform.googleapis.com,:generateContent. (gemini.go:322-336) - Vertex Imagen:
:predict. (vertexai.go:29) - Veo 3.1: the long-running-operation pair,
:predictLongRunningplus:fetchPredictOperation, with a hand-rolled poll loop. (veo/veo.go:37-38) - Cloud Storage: the JSON API with an ADC bearer token. The file's own comment: "consistent with the provider packages' no-SDK approach." (
internal/mcpserver/gcs.go) - Also raw HTTP: the Simple Icons CDN, the Iconify API, Google's faviconV2, and my own Cloud Run lip-sync service.
Everything hangs off one five-method interface:
type Provider interface {
Name() string
ID() string
Models() []Model
Generate(ctx context.Context, req GenerateRequest) (*GenerateResponse, error)
Edit(ctx context.Context, req EditRequest) (*EditResponse, error)
}
(provider/provider.go:61-67)
Here is the full cost of the vendor layer, in lines:
| File | Lines |
|---|---|
openai.go | 502 |
gemini.go | 464 |
vertexai.go | 397 |
veo.go (async video client) | 500 |
provider.go (interface) | 67 |
registry.go | 63 |
| Total | 1,993 |
1,993 lines. Three image providers, the shared interface, the registry, and a complete async video client. You can read the entire vendor surface of this system in one evening.
The part everyone assumes needs an SDK: Google auth
It doesn't. The entire replacement for a Google auth SDK is a 14-line RoundTripper:
// authTransport injects an OAuth2 bearer token into every outgoing request.
type authTransport struct {
base http.RoundTripper
source oauth2.TokenSource
}
func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
tok, err := t.source.Token()
if err != nil {
return nil, fmt.Errorf("gemini: failed to get access token: %w", err)
}
r := req.Clone(req.Context())
r.Header.Set("Authorization", "Bearer "+tok.AccessToken)
return t.base.RoundTrip(r)
}
(provider/gemini/gemini.go:130-144; the same pattern repeats in vertexai, veo, and the GCS client)
14 lines. Wrap the client, clone the request, inject the token. That's what the auth portion of a cloud SDK does for you, minus the twelve layers of option plumbing around it.

The honest asterisks
"Zero SDKs" without scope is marketing. There are three asterisks:
golang.org/x/oauth2/googlestays. It does the ADC and token-source work feeding that RoundTripper. That is auth plumbing, not an API client. Rebuilding Google's credential-discovery chain is not a good use of anyone's life.google.golang.org/api/idtokenappears in exactly one file (lipsync/generate.go). Cloud Run wants an ID token for service-to-service calls, and user ADC cannot mint one. One import, one job, one file.- The one protocol SDK is locked in a cell.
modelcontextprotocol/go-sdkv1.6.1 lives insideinternal/mcpserver, plus thestark-mcpmain that wires it up. No public package imports it. Anyone who imports myimagegenpackage inherits my 80-module graph, not the MCP SDK's.
That third asterisk deserves its own name: SDK quarantine. When you must take an SDK, take it inside an internal/ package so its graph never leaks into your public contract. A main that wires it up is fine: main is wiring, not contract. The quarantine is itself the dependency argument. I didn't skip SDKs out of purity. I skipped them because a dependency you export becomes everyone else's problem, forever. The rationale is written into the MCP server's design spec, not tribal knowledge.

What it buys
Concrete, measured on this machine, CGO_ENABLED=0, darwin/arm64:
stark-image: 10,548,514 bytes. A complete three-provider image client in roughly 10 MB, fully static.stark-mcp: 15,534,402 bytes. The entire 14-tool MCP server in roughly 15 MB.- 13 of the 15 binaries are cgo-free. The two exceptions (
stark-bg,stark-upscale) need cgo only for ONNX Runtime: local U²-Net matting and Real-ESRGAN. The repo docs call that "the one deliberate exception to the repo's cgo-free principle." - No linked codecs. Tools shell out to
ffmpegandimg2webpinstead of compiling codec libraries into the binary.
Static, small, boring binaries deploy anywhere: scratch containers, Cloud Run, a bare VM, my laptop. No glibc roulette, no native toolchain on the deploy path for 13 of 15 tools. Releases run on native runners only because the two ONNX binaries need real toolchains; the other 13 would cross-compile from anywhere.
What it costs: the real ledger
Verdict first: less than I expected, and not where I expected.
Typed request structs became pin tests. With raw HTTP, a typo'd json tag does not fail loudly. It silently drops a parameter on the wire. So the wire format is pinned by tests:
// TestPredictBodyMarshaling locks the wire field names: the REST API is
// camelCase and a typo'd json tag would silently drop a parameter.
func TestPredictBodyMarshaling(t *testing.T) {
(veo/veo_test.go:42-44)
Pin tests are the price of admission for raw HTTP.
Typed error hierarchies became 18 lines. parseHTTPError tries the provider's structured error envelope first, then falls back to a rune-safe truncated raw body. (openai.go:451-458) That covers every failure I have actually had to debug.
Retries were never built. Grep the entire cloud-calling tree, provider/, imagegen/, veo/, lipsync/, internal/: zero retry or backoff code. The only loop in the repo is the Veo poll, 10-second interval, capped at 8 minutes. This is the tradeoff that only works because of context. One user, interactive sessions, a human already in the loop who just runs the command again. In production, with real traffic, you pay for a retry layer, either hand-rolled or bundled in an SDK. I deliberately did not pay for one, and in 13 weeks I never once missed it. Knowing which costs your context lets you skip is most of engineering.
The one bill a typed SDK would have covered. Commit 0b5441c, May 30: "fix: wire config end-to-end and correct provider API calls." OpenAI rejects response_format for gpt-image models with an HTTP 400. The fix branches: output_format and background only for gpt-image, response_format only for dall-e. Same commit, Vertex wanted its safety filter value lowercased to the API's expected casing. This is exactly the class of bug a typed client surfaces at compile time. Total damage across the whole project: one deep-review commit. That's the entire typed-SDK dividend I gave up. Measured, not guessed.
And one bug no SDK on earth would have caught. The Gemini segmentation and alt-text calls must set thinkingConfig.thinkingBudget = 0 and responseMimeType: application/json. Leave thinking enabled and the model's reasoning text leaks into the JSON mask fields, and parsing breaks. The repo doc marks it "Observed live, do not remove." That's model-behavior churn. No client library types it away, because the response is valid JSON with garbage inside.
The Transport Illusion
The Transport Illusion is the belief that a vendor SDK insures you against API churn. It can't, because the churn isn't in the transport.

Look at what actually moved under this repo in 13 weeks:
| Layer | What changed |
|---|---|
| HTTP contract | Almost nothing. Same endpoints, same verbs, same envelopes. |
| Model IDs | GPT Image 1 deprecated, shutdown 2026-10-23, default already moved to gpt-image-2. |
| Product lines | The entire Vertex Imagen line shuts down mid-2026: Google's migrate-by date to Gemini image models is June 30, 2026. |
| Regions | gemini-3-pro-image-preview serves only from the global location, not from us-central1. Veo is regional-only, no global endpoint. The same vendor, opposite constraints. |
| Model behavior | The thinking-leak bug above. |
The transport barely moved. Every failure I debugged in 13 weeks came from the rows below it. The models, regions, and product lines churned constantly. An SDK version bump shields you from none of that. Model IDs, regions, and endpoints are configuration. Raw HTTP puts them in your own code, where a migration is a string change and a pin-test update. An SDK puts them behind someone else's release schedule and changelog.
And the meta-irony, since I promised bluntness about my own code: the repo's module path is still github.com/GetEvinced/stark-tools. The repo itself was transferred and renamed to 21StarkCom/stark-visual weeks ago. Renaming the module touches every import in the tree, so it waits as its own change. Even your own import path is dependency churn. Nobody is exempt, including me and my stale "14".
When to copy this, and when not to
Copy the raw-HTTP approach when:
- Your used surface is narrow. I use a handful of endpoints per provider. Reading the API reference for them takes an hour.
- You own all the callers. One repo, one team, or one person.
- The provider's churn lives in models, not protocol. True for image and video generation today.
- You will write pin tests. Non-negotiable. No pin tests, no raw HTTP.
Take the SDK when:
- The surface is wide: streaming, sessions, resumable uploads, pagination on every list call.
- You need bundled retries, telemetry, and auth edge cases, and you have real traffic that will find every one of them.
- Multiple teams will import your client. And even then, consider SDK quarantine so your importers don't inherit the graph.
Whichever way you go, scope the claim honestly. Mine is "zero vendor AI and cloud SDKs," with three named asterisks. Not "zero dependencies." A claim with visible asterisks survives scrutiny. A clean-sounding one dies on the first go mod graph.
Read the reference
1,993 lines buy three providers and a complete video client once you strip the insurance you didn't need. Google's Imagen migrate-by date, June 30, 2026, has already passed as I write this. My migration is a model ID string and one pin test, not an archaeology dig through a library changelog. When a response breaks, the request that caused it is in my code, not twelve frames down someone else's stack.
The vendors will keep churning models under all of us. That part is guaranteed. The only question is whether the layer that absorbs the churn is code you can read in an evening, or a tree you've never opened.
So next time your reflex says go get, open the API reference first. Count the endpoints you actually need. If it is fewer than ten, the SDK is not saving you work. It is saving you from reading. Don't let it.