21STARK
All posts
11 min read views

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/generations and /v1/images/edits. Bearer token in a header. Edits use stdlib mime/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, :predictLongRunning plus :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:

FileLines
openai.go502
gemini.go464
vertexai.go397
veo.go (async video client)500
provider.go (interface)67
registry.go63
Total1,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.

Claymation scene of an orange robot arm pressing a round orange wax seal onto white envelopes riding a conveyor belt, with stamped envelopes flying off toward a blue sky.

The honest asterisks

"Zero SDKs" without scope is marketing. There are three asterisks:

  1. golang.org/x/oauth2/google stays. 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.
  2. google.golang.org/api/idtoken appears 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.
  3. The one protocol SDK is locked in a cell. modelcontextprotocol/go-sdk v1.6.1 lives inside internal/mcpserver, plus the stark-mcp main that wires it up. No public package imports it. Anyone who imports my imagegen package 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.

Comic-style illustration of a robot octopus with segmented cable tentacles sealed inside a thick glass cube in a bright white laboratory, with shelves of orange toolboxes lining the walls out of its reach.

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 ffmpeg and img2webp instead 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.

Two-ink risograph print of a sturdy steel railway bridge in orange and deep blue, while two tower cranes above the track lift one toy train away and lower a different one in.

Look at what actually moved under this repo in 13 weeks:

LayerWhat changed
HTTP contractAlmost nothing. Same endpoints, same verbs, same envelopes.
Model IDsGPT Image 1 deprecated, shutdown 2026-10-23, default already moved to gpt-image-2.
Product linesThe entire Vertex Imagen line shuts down mid-2026: Google's migrate-by date to Gemini image models is June 30, 2026.
Regionsgemini-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 behaviorThe 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.

Get in touch

I write about AI-first engineering on LinkedIn. Specs in, production out, nobody types code. Follow along there, or send a note.

hi@21stark.com · LinkedIn opens my profile, message me from there


Or send it from here