DEV Community

thebrecht
thebrecht

Posted on

I repositioned my URL shortener 3 months after launch — the moment it clicked

I launched toui.io in early April as a solo side project — a URL shortener I built because I wanted something cleaner and faster than what I was using. The name comes from Taiwanese dialect: "佗位" (tó-uī) means "where to?" which felt right for a service that takes you from A to B.

It worked. People signed up, shortened links, liked the name. But every time someone asked "what makes this different from Bitly, Short.io, Dub, or any of the other shorteners?" — I'd list feature differences and feel a bit dishonest. I knew those were differences, not a positioning.

That gap nagged at me for two months.

The "differentiator" trap

When you build in a mature market, you naturally look at the incumbents and try to find gaps. Better free tier? Cleaner API? No interstitial ads? More generous limits?

But none of those are a reason to exist. They're table stakes with a slightly different arrangement. I could list them all day and still not answer "why should I switch?"

I was reading a book by the founder of Tsutaya (Japan's largest bookstore chain). His argument: in an era of product surplus, the only thing that creates desire is a lifestyle proposition — not features, but a point of view about how the customer should live. I realized my product had features but no point of view.

The moment it clicked

I spend most of my working hours inside Claude Code CLI. It's where I write code, manage infrastructure, draft emails, review PRs — essentially my entire working environment is a terminal with an AI conversation.

One afternoon I wanted to check click stats on a short link. The thought of switching to a browser, logging in, navigating to the dashboard... it felt like friction. I caught myself thinking: why can't I just ask Claude?

That was it.

toui was built around my past habits — visiting a website to manage links. It wasn't woven into how I actually work now, which is mostly inside AI conversations. The product needed to live where I live.

What I did about it

I spent several weeks on three things:

  1. Built an MCP Server (mcp.toui.io) so the shortener plugs directly into Claude, ChatGPT, Cursor, VS Code, and Cline. Say "shorten this link" or "show me stats" and it works without leaving the conversation.

  2. Redesigned the entire site to communicate this positioning clearly — not "another URL shortener" but "the short-link service embedded in your AI workflow."

  3. Restructured the API and data model so AI tools can do more than just shorten — they can query analytics, group links into campaigns, and report on performance.

The recursive part: Claude Code built the MCP feature that brings the product back into Claude. The tool built the thing that makes the tool more useful.

What positioning actually gave me

Before: I was staring at competitors' feature lists wondering what to copy next.

After: I know exactly where to invest — deeper AI integration, campaign analysis inside the chat, making every link operation something you'd never need to leave your workflow for. I stopped chasing feature parity with giants and started building toward a thesis.

The product is the same under the hood. Same short links. But it has a direction now.

The takeaway for builders

If you're stuck in the "what's my differentiator" loop, here's what worked for me: look at where you actually spend your hours today, not where your product assumes you do.

My shortener assumed I'd visit a website. I don't visit websites anymore — I talk to AI. Once I saw that gap between the product's assumption and my actual behavior, the positioning wrote itself.

Your product might have the same blind spot. Where do your users actually spend their time — and is your product meeting them there?


toui.io — the short-link service embedded in your AI workflow

Top comments (4)

Collapse
 
vollos profile image
Pon

I reread the recursive part a couple times — Claude Code building the MCP feature that pulls the product back into Claude. I work the same way; most of my day is in the CLI, and "why can't I just ask Claude" is the friction that keeps reshaping what I build too.

When I wired up something similar, it bit me: once an MCP server can query analytics and manage campaigns, it's a real data API. Claude Code wrote mine, it worked on the first try, returned the right data — but the auth scoping wasn't there. Any token could read links that weren't its own. It passed every test I threw at it, because I was only ever testing as me.

How are you scoping which links a caller can see through the MCP layer — tied to the user behind the token, or does the server trust whatever id comes in?

Collapse
 
thebrecht profile image
thebrecht

Tied to the token — and the thing that makes it hold is that no id ever comes in.

The MCP surface has no id inputs. get_campaign_stats takes a campaign name, get_url_stats takes a short code, and both get resolved inside the caller's team before anything is read. Every read is a single scoped statement (WHERE id = ? AND team_id = ?), never "fetch it, then check who owns it." So a code or name belonging to someone else doesn't come back 403 — it comes back not found. campaign_id only exists on the way out.

That was deliberate, for exactly the reason you hit: an id you accept is an id you have to remember to authorize. A name you resolve within a scope can't be a confused deputy.

The subtler problem is one layer up, and it's the part I'd flag to anyone building this. The team is never baked into the token. Bearer keys re-check the database on every request — the existing key-validation path does it for free, so the suspend kill-switch just works. OAuth grants inherit none of that: they're long-lived, and the provider never re-queries. Same check, silently absent on one of the two auth paths — a suspended user, or someone removed from their team, would have kept working off a perfectly valid token. It got caught in review before the OAuth path could mint a token, and membership is now re-resolved per request, with a missing row treated as a revoke. But that's the shape of it: you test one auth path and quietly assume the other inherits its guarantees.

And the fix for "I was only ever testing as me" turned out to be embarrassingly cheap: the fixture has to seed a second tenant. Two of my tests exist only to reach across the boundary and be told the thing doesn't exist. They'd have caught your bug on the first run.

Curious which way yours went — per-user tokens, or one service credential on the server? That's the fork where this gets genuinely hard.

Collapse
 
vollos profile image
Pon

Mine hit both sides of your fork, in the wrong order. v1 was one service credential in the worst spot: the client, a Chrome extension that reads customs paperwork, called the model API directly with the key in hand. Claude Code wrote it, it worked, and a working demo says nothing about where a credential is standing. The rewrite inverted the whole thing: model key server-side only, callers reach the backend with their own JWT, tenant scoping enforced in the query like yours. So per-user tokens for the data, one service credential for the model, and v1 had neither where it mattered.

I'm filing away the OAuth half of your answer. Two auth paths where one silently inherits none of the re-checks is the bug a single-tenant suite will happily bless. The two fixtures that only exist to reach across the boundary and hear not found are going straight into how I test my own stuff.

Thread Thread
 
thebrecht profile image
thebrecht

"A working demo says nothing about where a credential is standing" — that's the
whole thing in one line, better than I put it. Green only proves the happy path
ran, never where the key was standing while it ran.

Fair trade, then: you took the two-path check and the second-tenant fixtures,
I'm taking that line. And you'd already done the hard part — key moved to where
it takes a breach to reach, JWT where the boundary can actually hold. Getting
out in the right order the second time is the whole game — looking forward to
what you ship next.