Rewriting the Teams CLI
I’m a maintainer for the Microsoft Teams SDK, which simplifies the contract between the Teams platform and external services. We’d been spending a lot of time rebuilding the SDK over the past year, but when we did some user research, we discovered something surprising: the main difficulty folks face when building for our platform isn’t the integration the SDK simplifies — it’s onboarding onto the platform in the first place.
As an engineer on the team, I already knew onboarding wasn’t trivial. I’d written a pile of shell scripts to do it for myself, and over time I’d accumulated even more to tweak settings and manage my apps. Once I learned that onboarding was one of the biggest hurdles for everyone else too, I decided to formalize those one-off scripts into an easy-to-use CLI that anyone could use to onboard and manage their Teams applications.
I’d never built a CLI before, so before planning the build I studied the ones I admired — GitHub, Azure, AWS — to get a feel for what makes a good one.
One idea shaped almost every decision that followed: a CLI has two very different users. Humans want approachability, good defaults, and legible output. Agents want structure, predictability, and clear recovery paths. The best commands serve both, and where they diverge, you give each audience what it needs. That tension is the throughline for everything below.
Progressive complexity
Teams traditionally frontloads a lot of complex concepts before the user has done anything meaningful. To get started, a developer needs to understand what an app is, what a bot is, what sideloading is, what a manifest is — on and on. The point is that they have to wade through docs and build an elaborate mental model just to begin.
With the CLI, I wanted the opposite: something extremely approachable that required understanding as few concepts as possible. The result was a single command:
teams app create --name="My Agent" --description="My custom agent"
This registers the app, gives you a secret so you can authenticate as it, and hands you an installable link. With just that, the developer already has a usable, installed application.
- The developer is introduced to nothing beyond
app, which is fairly intuitive on its own. Other onboarding flows separate the concept of anappfrom abot, requiring you to create each one and then link them. But the vast majority of apps will always create both together, so I made that the default. - Other required fields, like the webhook endpoint, are introduced later — once the user has built a service that can actually handle incoming webhook messages.
Interactive for humans
CLIs like az ask you to pass a lot of flags in a single command, which means knowing a lot of details before you can run anything. Take this simple command:
teams app get <app-id>
Running it requires you to know the app-id. To get that, you’d run teams app list, scan all your apps, find the one you care about, copy its id, and finally pass it to the command above.
For agents, that’s fine — they can pull ids from a .env file or run both commands in quick succession. For humans, running both is tedious and, honestly, unnecessary.
So I made the CLI interactive:
If id isn’t provided, the CLI does the intuitive thing: it runs list for you, makes the results selectable, and once you pick one, hands it straight to teams app get.
Make waiting informative
The CLI makes a number of API calls, and some of them take a while — usually a few seconds, occasionally tens of seconds. For every one of those moments, I made sure to show a spinner that explained what was actually happening. The spinner component has a required details field, so no public-facing wait can ship without a reason attached to it.
Little touches like this really add up, in my opinion.
Prefer sensible defaults
The CLI shouldn’t demand a lot of decision-making or cognitive overhead. Only the truly essential arguments should be required; everything else should be optional. The barrier to entry stays low and intuitive, without restricting people who want to reach for advanced options.
App creation is a good example. Using the Developer Portal, or constructing the manifest by hand, forced a series of decisions before you could even start: app name, developer details, descriptions, and so on. With the CLI, the only requirement is the app’s name. That’s it. Where the credentials live, what the endpoint is, which icons to use — all of it is customizable, but none of it is required.
Dedicated commands for common operations
This one sounds obvious, but it required deeply understanding the issues users hit on a regular basis and building those directly into the tool. Beyond basics like creation and customization, users are constantly modifying their apps to support new features — OAuth, targeted messages, and others. Rather than asking them to follow a five-step process each time, I added dedicated commands for those cases.
For instance, a common task is editing the manifest so an app supports a particular feature. The CLI lets you pull your manifest and upload it, but editing it by hand is still cumbersome. So I added a dedicated --set-json <path.to.value>=<value> flag: it modifies the manifest and reuploads it for you in a single command. Feature docs can now embed these as copy-pasteable commands. The payoff is better feature adoption, less cognitive overhead, and a generally more pleasant experience.
--json mode
Beautiful output helps humans — it eases comprehension and legibility. Agents could not care less. They often prefer plain text or structured output. Many CLIs, GitHub’s included, offer a --json mode: a global flag that returns any result as structured JSON.
This lets the user — or the agent — drop CLI commands into more complex scripts tailored to their own use case, without writing and maintaining brittle parsing logic.
Actionable errors
An error message that only tells you what went wrong isn’t very helpful. One that also tells you what to do next is far more valuable, especially for agents. Agents are remarkably resourceful at working around problems when they have a clear path forward. So one principle I held throughout the CLI’s error handling was that every error should say both (a) what happened and (b) how to fix it.
Take a common one: a user’s tenant doesn’t have sideloading enabled. (Sideloading is how you load an app during development.) Instead of flatly reporting “sideloading is disabled in your tenant,” the error explains how the user — or their admin — can actually enable it and move forward.
It seems obvious, but the benefits are big. Users get unblocked faster, and we carry less operational burden helping debug issues.
Conclusion
I had a lot of fun building this CLI. The two design instincts that turned out to matter most — structured --json output and actionable errors — are precisely the ones that make the tool legible to coding agents, and that’s driven a real influx of new users into our systems as those agents have taken off. Building an entire product around good practices, and around what I’ve learned over the past year living deep inside this platform, has been a genuinely rewarding exercise.