Skip to main content

Guides

I Stopped Opening My Own App

The interface became optional. The logic and the data stayed valuable. This is the implementation story behind the tennis video, with the parts a video cannot hold.

Naveed Rafi

Noodle Seed

July 22, 2026
7 min read
Drone shot of a tennis serve on a grass court, ball tossed high above the player

TL;DR

  • My tennis app already had the API it needed. I wired it to an MCP server with one TypeScript file and three thin tools, so now I say “won my quarter-final, 6-4 7-5” instead of clicking through five screens.
  • No logic moved. Validation, bracket advancement, and permissions all stayed in the app. The agent talks. The app decides.
  • Make a workflow headless when people already say it in a sentence. Keep it on a screen when the value is in seeing. The draw page stayed, and got better.
  • Spec to deployed, connected MCP server: one afternoon, two commands (noodle dev, noodle deploy).

I won my tennis quarter-final last week. Six-four, seven-five.

I know the score precisely because I'm the one who typed it into the tournament app. From the bench. Like I do every match, for every player, every evening. A few months ago I vibe-coded Centre Court, an app for me and my friends to run our tennis tournaments. It worked, and that was the problem.

The app did exactly what apps do. It gave everyone a beautiful draw to look at, and it gave me a login page, a tournament list, a match list, a score form, and a save button. Multiply that by every match. Add the 15 emails and 30 WhatsApp messages about who actually won what. I had built software to escape admin and become its administrator.

The only door · five screens per scoreAnd the chasing, every weekYouevery eveningLog inFind tournamentFind matchType the scoreSaveCentre Courtthe draw everyone lovesa REST API already insidethe UI is the only way inYour friendsthey want the score“Who actually won what?”15 emails · 30 WhatsApp messages
Before: one door. Every score is a rally through five screens, the results still get chased over WhatsApp, and the API that could take the score in one sentence sits inside, unused.

The realization

The fix wasn't another screen. Centre Court already had an endpoint to update matches. I'd built it. What I actually wanted was to skip the interface entirely and just tell the app:

“Won my quarter-final, 6-4 7-5.”

In tennis terms, I'd been rallying back and forth with my own software: click, navigate, click, type, save. I could win the point with an ace.

That's what “headless” means here. Not killing the app. The draw page still exists, and my friends still check it obsessively. Headless means the interface stops being the only door. The logic and the data, the actual software, become something you can talk to.

How I did it, for real this time

The starting point that made this easy: Centre Court is a bog-standard Next.js app with a REST API, and it serves its own OpenAPI 3.1 spec at /api/openapi. Every operation has a stable operationId, like listTournaments and recordMatchResult. If your app has that, you're most of the way there and you don't know it yet. If it doesn't, generating a spec from your routes is the real first step of going headless. Everything after this leans on it.

The move: I copied the spec and handed it to my coding agent. A Noodle Seed project ships with a skill for coding agents, so the agent knew the target shape without me explaining anything. What it wrote is one TypeScript file: a declarative HTTP connector pointing at my existing API, and three thin tools on top.

const centreCourt = connector('centre_court_api')
  .http({
    baseUrl: 'https://centrecourt.app',
    auth: { kind: 'bearer', secret: 'CENTRE_COURT_TOKEN' },
    operations: {
      list_tournaments:    { method: 'GET',   path: '/api/tournaments' },
      get_tournament:      { method: 'GET',   path: '/api/tournaments/{id}' },
      record_match_result: { method: 'PATCH', path: '/api/tournaments/{id}/matches/{matchId}' },
    },
  });

export default server('centre_court', { use: { cc: centreCourt } }, [
  tool('list_tournaments',   { /* find a tournament by name */ }),
  tool('get_draw_status',    { /* players, ids, every match in the draw */ }),
  tool('record_match_score', { /* winnerId + set scores, the PATCH I already had */ }),
]);

That's the entire server. No business logic. No protocol handling. The token lives as a managed secret via noodle secrets set CENTRE_COURT_TOKEN, never in source.

Notice what the tools don't do. There is no advance_the_bracket tool. There's no validate_scoretool. When I say “won my quarter-final, 6-4 7-5,” the agent calls get_draw_status, resolves me to a player id and my match to a match id from the data, and then calls record_match_score. My app does what it has always done. It rejects impossible set scores, marks the match complete, advances the winner, and updates the draw for everyone. The agent talks. The app decides.

Then the two commands from the video: noodle dev runs the server locally. Loopback only, hot reload, and it prints a local MCP endpoint for testing. noodle deploy puts it live and prints a hosted endpoint. Connect that to Claude with noodle connect claude-code, or paste it into any MCP client's connector settings, and you're done. Owner-only auth by default, which is exactly right: everyone sees the draw in the app, and only I can write scores through the agent.

Door one · tellingDoor two · seeingYouvoice or textClaudeor any MCP clientNoodle Seed serverthree thin tools + connectorsecret stays server-sideCentre Courtthe REST API I already hadvalidates scoresadvances the bracketowns the dataYour friendsany browserDraw pagethe UI staysspeakMCPHTTPSbrowse
After: two doors into the same building. The agent path and the browser path end at the same API, every rule stays inside Centre Court, and the score takes one sentence.

Where to draw the line

This is the part I wish someone had written down for me, because the temptation is to make everythingheadless, and that's how you build a worse app twice.

Make it headless when the action is something people already say in a sentence. “Won my quarter-final, 6-4 7-5” was being typed into WhatsApp fifteen times a week anyway. The conversational interface already existed; it was just disconnected from the software. Bounded verbs, high frequency, low ambiguity, and validation already living server-side: those are your candidates. Reporting, logging, updating a status, recording a decision.

Keep it on a screen when the value is in seeing, not telling.The draw page didn't just survive going headless. It got more valuable, because now it updates without anyone doing data entry. Nobody wants to ask an agent to describe a bracket and read four paragraphs. Shared, glanceable, visual state belongs on a screen. So does anything where a human is comparing options rather than stating a fact.

Keep the tool surface thin, and keep the logic where it lives.The rule that kept me honest: if a tool starts computing something my app already computes, I'm building in the wrong layer. The bracket advancement, the score validation, and the who-is-allowed-to-do-what all stayed put. Headless isn't a rewrite. It's a second door into the same building. The moment your MCP server has its own opinion about your domain, you have two sources of truth, and you will spend your weekends reconciling them.

One sentence to keep: conversation is for telling; interfaces are for seeing. Route each workflow accordingly.

What actually bit me

Three honest snags, so your afternoon goes faster than mine:

  • Local dev tripped a security guard. The connector layer protects against SSRF, short for server-side request forgery, an attack where a hosted server is tricked into calling addresses on a private internal network. So it resolves DNS and refuses names that point at loopback. During local testing, point the connector at a literal 127.0.0.1, not localhost. Ten confusing minutes if you don't know this, ten seconds if you do.
  • Response mappings are deliberately flat.You map fields off your API's response, like ${response.data.tournament.players}. You don't reshape deeply nested structures in the tool layer. Annoying for a moment, until you realize it's the guardrail enforcing the thin-server rule above.
  • Set your target before setting secrets. Local secrets are scoped. Running noodle target set before noodle secrets set saves you the error message I got.

None of these changed the shape of the afternoon. The whole thing, from spec to a deployed and connected MCP server, genuinely fit between lunch and my quarter-final.

Why this matters beyond tennis

Every piece of software you've ever built already has the hard part done: the APIs, the logic, the data model. What it's missing is a way for AI agents, the place your users increasingly already are, to call it safely. That layer covers MCP protocol handling, OAuth, secrets, policy, rate limits, hosting, and observability, and it is the same machinery every single time. Building it per app is how you spend a quarter. Inheriting it is how you spend an afternoon.

That's the bet behind Noodle Seed, and my co-founder Asad makes the wider case in Headless Software Is Just Beginning. Software isn't dead. It's going headless.

Try it on your own API

No account, no login, nothing to sign:

npm install -g @noodleseed/one
noodle init my-app && cd my-app
noodle dev

Point it at an API you already have, and see how far a sentence gets you. The docs at docs.noodleseed.dev cover the wiring in full.

Your software already has APIs. Which one will you make headless? Tell me. The best answer gets built, on camera, in a future video.

Watch the full story (2:56): the Go Headless film. Stay for the last ten seconds.