Skip to main content
Teams

API-based Message Extensions: Teams extensibility without a bot

API-based Message Extensions: Teams extensibility without a bot

If you’ve been following this blog for a while, you might remember the Teams Ramp-Up series I did back in 2019. Four posts. Four! Just to get from zero to a working Teams app. Setting up the dev environment, figuring out ngrok, registering a bot, wiring up the manifest. And that was the simple path. API-based Message Extensions are now GA and honestly, they make all of that feel like we were doing things the hard way. Because we were.

Remember the old days

I wrote an entire post about ngrok. A whole blog post dedicated to a tunneling tool just so Teams could talk to your local machine. Then there was the bot registration, the Azure App Service, the Bot Framework SDK with its activity handlers and turn contexts. I even wrote about speeding up the development process because the manifest workflow was so painful I had to automate it with gulp. All of that infrastructure, all of those moving parts, just so a user could type a search query in the compose box and get some results back. It worked, don’t get me wrong, but it always felt like we were bringing a tank to a knife fight.

What are API-based Message Extensions

The concept is refreshingly simple. You write an API. You describe that API with an OpenAPI specification. You point Teams at the spec. Teams generates the UI. That’s it.

No bot registration. No Bot Framework SDK. No webhook endpoint that needs to stay alive. No ngrok during development if your API is already hosted somewhere. Your API can live anywhere - Azure Functions, an App Service, AWS Lambda, a Raspberry Pi in your basement. I don’t judge. As long as it responds to HTTP requests and matches the OpenAPI spec, Teams is happy.

First off, this doesn’t mean bots are dead. Far from it. But for the very common scenario of “I want users to search something from the compose box,” this is a massive simplification. Teams reads your OpenAPI spec, understands what parameters your API expects, builds the search UI with the right input fields, calls your API, and renders the results as cards. You focus on your data and your logic. Teams handles the rest.

Let’s build one

Let’s walk through a concrete example. Say we want to build a project search extension so people can look up internal projects right from the Teams compose box. We start with the OpenAPI spec:

openapi: 3.0.3
info:
  title: Project Search for Teams
  version: 1.0.0
  description: Search internal projects directly from Teams

servers:
  - url: https://my-api.azurewebsites.net/api

paths:
  /projects/search:
    get:
      operationId: searchProjects
      summary: Search for projects by name or client
      parameters:
        - name: query
          in: query
          required: true
          schema:
            type: string
          description: Search term for project name or client
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [active, completed, archived]
          description: Filter by project status
      responses:
        "200":
          description: Matching projects
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        projectId:
                          type: string
                        name:
                          type: string
                        client:
                          type: string
                        status:
                          type: string
                        lastUpdated:
                          type: string
                          format: date

Nothing fancy. A GET endpoint with a required search query and an optional status filter. Now we need the actual API behind it. If you’re using .NET minimal APIs, this is almost embarrassingly short:

app.MapGet("/api/projects/search", async (
    string query,
    string? status,
    ProjectDbContext db) =>
{
    var results = await db.Projects
        .Where(p => p.Name.Contains(query) || p.Client.Contains(query))
        .Where(p => status == null || p.Status == status)
        .OrderByDescending(p => p.LastUpdated)
        .Take(10)
        .Select(p => new
        {
            p.ProjectId,
            p.Name,
            p.Client,
            p.Status,
            p.LastUpdated
        })
        .ToListAsync();

    return Results.Ok(new { results });
});

That’s your entire backend. Compare that to the hundreds of lines of Bot Framework code we used to write. I’ll wait.

Now the last piece is the Teams app manifest. This is where we tell Teams that we’re using an API-based extension instead of a bot-based one. The key part is the composeExtensions section:

{
  "composeExtensions": [
    {
      "composeExtensionType": "apiBased",
      "apiSpecificationFile": "openapi.yaml",
      "commands": [
        {
          "id": "searchProjects",
          "type": "query",
          "title": "Search Projects",
          "description": "Find projects by name or client",
          "parameters": [
            {
              "name": "query",
              "title": "Search",
              "description": "Project name or client to search for",
              "inputType": "text",
              "isRequired": true
            },
            {
              "name": "status",
              "title": "Status",
              "description": "Filter by status",
              "inputType": "choiceset",
              "isRequired": false,
              "choices": [
                { "title": "Active", "value": "active" },
                { "title": "Completed", "value": "completed" },
                { "title": "Archived", "value": "archived" }
              ]
            }
          ]
        }
      ]
    }
  ]
}

Notice the composeExtensionType is set to apiBased instead of the old botBased. The apiSpecificationFile points to your OpenAPI spec which you include in the app package. The commands array maps to the operations in your spec. This way Teams knows exactly what to render and what to call.

What about authentication

Of course, you probably don’t want just anyone hitting your API. There are two main approaches here. The simplest is API key authentication. You register an API key in the Teams Developer Portal, and Teams will send it along with every request to your API. Your API validates the key, done.

For scenarios where you need to know who the user is, you can use SSO with Microsoft Entra. Teams will acquire a token on behalf of the user and pass it to your API. Your API validates the token and you know exactly which user is making the request. This is the way to go if your search results should be filtered based on permissions, which in most enterprise scenarios they should be.

When NOT to use this

Now I’d be doing you a disservice if I didn’t mention the limitations. API-based Message Extensions are great for search commands, but they can’t do everything a bot can do. If you need proactive messaging, where your app sends messages to users without them initiating it, you still need a bot. If you need multi-turn conversations where the app asks follow-up questions, you need a bot. If you need adaptive card actions that trigger server-side logic, you need a bot.

Think of it this way: if your extension is essentially “user asks, API answers,” then API-based is the way to go. If your extension needs to have a conversation or push information, you still need the full bot infrastructure. For most search-based message extensions I’ve built over the years, the API-based approach would have saved me days of work. The choice is up to you.