Skip to content
CloudOps
Newsletter
All guides
AI for Microsoft Teams By James Joyner IV · · 10 min read

Validate Your Teams App Manifest in CI Before It Breaks

A bad manifest fails at upload, in front of everyone. Here's how to lint, schema-validate, and version your Teams app manifest in CI so bad packages never ship.

  • #microsoft-teams
  • #manifest
  • #ci-cd
  • #teams-toolkit
  • #validation

I have a specific kind of dread, and it has a timestamp. It’s the moment a teammate shares their screen in front of a room of stakeholders, drags our freshly-built app package into the Teams admin center, and the upload bounces with a red error about a field nobody can pronounce. The demo stalls. Someone says “let me try a different zip.” It never gets better from there.

The infuriating part is that every one of those failures was catchable. A Teams app manifest is just a JSON file with a published schema, two PNG icons, and a few rules about what has to line up. There is nothing about it that requires a human to discover the problem live. So this post is about moving that discovery left — into CI, where a failing check is boring and private instead of dramatic and public.

I’ll also be honest about how I write these pipelines now. I let an AI assistant — Claude or Copilot — draft the GitHub Actions job and the manifest skeleton, because it’s genuinely fast at boilerplate. But I treat it like a sharp junior engineer: quick, confident, occasionally wrong about the thing that matters. I review every line before it touches a real tenant.

What’s actually in the package

A Teams app package is a flat zip with exactly three things at its root:

  • manifest.json — the declaration of what your app is and what it’s allowed to do.
  • A color icon, color.png, which must be 192x192 pixels.
  • An outline icon, outline.png, which must be 32x32 pixels, transparent, single-color.

That’s it. The zip has no folders — the files live at the top level or Teams won’t read it. Most “my package won’t upload” tickets I’ve triaged were one of three things: a nested folder in the zip, an icon at the wrong dimensions, or a manifest that drifted out of sync with its schema. All three are trivially checkable in a pipeline.

The manifest itself opens with two fields that do a lot of work:

{
  "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.19/MicrosoftTeams.schema.json",
  "manifestVersion": "1.19",
  "version": "1.4.2",
  "id": "00000000-0000-0000-0000-000000000000",
  "name": { "short": "Deploy Bot", "full": "Deployment Status Bot" },
  "developer": {
    "name": "Acme Platform Team",
    "websiteUrl": "https://acme.example.com",
    "privacyUrl": "https://acme.example.com/privacy",
    "termsOfUseUrl": "https://acme.example.com/terms"
  },
  "validDomains": ["acme.example.com"],
  "webApplicationInfo": {
    "id": "11111111-1111-1111-1111-111111111111",
    "resource": "api://acme.example.com/11111111-1111-1111-1111-111111111111"
  }
}

The $schema value points at the JSON schema for the manifest; the manifestVersion string must match the version embedded in that URL. These two need to stay in lockstep — if you bump manifestVersion to 1.19 but leave $schema on a v1.16 URL, validation gets confused and the platform may reject capabilities you legitimately declared.

Schema-validate it like any other JSON contract

The single highest-value check is validating manifest.json against the published schema. Microsoft hosts these at developer.microsoft.com (and mirrors them at schema.developer.microsoft.com), versioned per manifestVersion. Because it’s plain JSON Schema, you don’t need anything Teams-specific to enforce it — ajv-cli does the job:

# Pin the schema locally so CI isn't hostage to a network blip
ajv validate \
  -s schemas/MicrosoftTeams.v1.19.schema.json \
  -d appPackage/manifest.json \
  --spec=draft7 --strict=false

I download the schema file into the repo and pin it rather than fetching the live URL on every run. That makes builds reproducible and means a Microsoft CDN hiccup can’t fail my pipeline. When I intentionally bump manifestVersion, updating the pinned schema is a deliberate, reviewable commit.

Pro Tip: ajv’s --strict=false matters here. The Teams schema uses constructs ajv flags in strict mode, and without the flag you’ll get failures that are about ajv’s opinions, not about your manifest. Turn strict off so your red checks mean something real.

Then validate it the Teams way

Schema validation catches structural problems. It does not catch Teams-specific business rules — like an icon being the wrong size, or a bot ID that doesn’t match a registered bot. For that, use the Teams Toolkit CLI, which ships as teamsapp (the binary is also commonly aliased atk):

npx @microsoft/teamsapp-cli validate --manifest-path appPackage/manifest.json
# or, against a built zip:
npx @microsoft/teamsapp-cli validate --app-package-file-path build/appPackage.zip

teamsapp validate runs the same package-level checks the upload would, so a green result here is a strong signal the package will actually install. For the final gate before a tenant rollout, the Developer Portal (dev.teams.microsoft.com) has its own validation and a “validate your app” flow that mirrors the store submission rules — worth a manual pass when you’re about to publish broadly, even if CI already passed.

The two layers are complementary: ajv tells you the JSON is shaped correctly; teamsapp validate tells you Teams will accept it.

Don’t skip webApplicationInfo and validDomains

Two fields cause more SSO and “content won’t load” pain than the rest combined.

webApplicationInfo wires up single sign-on. Its id is your Microsoft Entra (Azure AD) app registration’s client ID, and resource is the Application ID URI you configured for that registration. If the resource here doesn’t exactly match the URI in Entra, SSO token requests fail silently in a way that’s miserable to debug from the Teams client. CI can’t verify the Entra side, but it can assert the field exists, is a well-formed GUID, and that resource is a valid URI — cheap insurance against a typo.

validDomains lists the domains Teams will load inside your tabs and dialogs. Anything not on this list gets blocked. A common self-inflicted outage: shipping a new feature hosted on a subdomain you forgot to add. Add a CI assertion that every URL in your manifest’s content lives under a domain that appears in validDomains.

Pro Tip: never put wildcards like *.example.com in validDomains unless you genuinely need them, and never include domains you don’t control. A loose validDomains entry is an open door for content injection — this is exactly the kind of “looks harmless” line an AI assistant will happily add to make your build pass. Reject it.

Bump the version on every change

version is semver, and Teams uses it to decide whether an updated package is actually an update. If you change the manifest but leave version the same, admins may not see your change roll out — the catalog treats it as identical. So: every manifest change gets a version bump. No exceptions.

I enforce this mechanically. A CI step diffs the manifest against the previous commit and fails if manifest.json changed but version didn’t:

if ! git diff --quiet HEAD~1 -- appPackage/manifest.json; then
  prev=$(git show HEAD~1:appPackage/manifest.json | jq -r .version)
  curr=$(jq -r .version appPackage/manifest.json)
  [ "$prev" != "$curr" ] || { echo "::error::manifest changed but version not bumped"; exit 1; }
fi

It’s a small rule that has saved me from the confusing “I deployed it but nobody sees it” conversation more than once.

Wire it all into one GitHub Actions gate

Here’s the job that ties the layers together. It’s the kind of thing I’ll have Copilot draft and then trim down — it loves to over-add steps.

name: validate-teams-app
on: [pull_request]

jobs:
  manifest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 2 }

      - uses: actions/setup-node@v4
        with: { node-version: 20 }

      - name: Schema validation (ajv)
        run: |
          npx ajv-cli@5 validate \
            -s schemas/MicrosoftTeams.v1.19.schema.json \
            -d appPackage/manifest.json \
            --spec=draft7 --strict=false

      - name: Icon dimensions
        run: |
          color=$(node -e "const s=require('image-size')('appPackage/color.png');console.log(s.width+'x'+s.height)")
          outline=$(node -e "const s=require('image-size')('appPackage/outline.png');console.log(s.width+'x'+s.height)")
          [ "$color" = "192x192" ]  || { echo "::error::color.png must be 192x192 (got $color)"; exit 1; }
          [ "$outline" = "32x32" ]  || { echo "::error::outline.png must be 32x32 (got $outline)"; exit 1; }

      - name: Teams Toolkit validate
        run: npx @microsoft/teamsapp-cli validate --manifest-path appPackage/manifest.json

      - name: Version bump enforced
        run: bash scripts/check-version-bump.sh

Make this job a required status check on the branch. That’s the whole point: a malformed package physically cannot merge to the branch the App Catalog deploys from. The check is the gate, not a suggestion.

Where the AI helps — and where it stops

Drafting this pipeline by hand is tedious, and that’s exactly the work I hand to an assistant. A good prompt — “write a GitHub Actions job that schema-validates a Teams manifest with ajv, checks icon dimensions, runs teamsapp validate, and enforces a semver bump” — gets you 80% of the way in seconds. If you want that prompt to be repeatable, keep it in a shared library; I store mine in our prompt workspace and lean on curated prompts and prompt packs so the whole team starts from the same vetted baseline instead of re-improvising.

But the assistant is a fast junior engineer, not a release manager. It doesn’t know your Entra app registration’s real Application ID URI, it won’t notice that a connector or incoming webhook is posting to an unauthenticated endpoint, and it has no instinct for which validDomains entry is a quiet security hole. So the rule in my team is firm: never hand the model real tenant credentials, never let it author the final security-sensitive lines unreviewed, and treat any connector or webhook it wires up as guilty until you’ve verified the auth yourself. Tools like Claude and GitHub Copilot draft; a human reviews before anything reaches a tenant. If you want more on getting started with Teams automation, the Microsoft Teams category collects the rest of our playbooks.

Conclusion

A Teams manifest failing at upload is the most preventable demo disaster I know of. Pin the schema, validate with ajv, validate again with teamsapp, check your icons and validDomains, and refuse to merge a manifest change without a version bump. Let the AI write the YAML fast — then be the adult who reads it before it ships. The goal isn’t a perfect manifest on the first try. It’s making sure the imperfect one fails quietly, in CI, where only you are watching.

validate-your-teams-app-manifest-in-ci-before-it-breaks

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.