@powellsoftware/connector-cli
v0.1.7
Published
Scaffold a personalized Powell Connectors declarative-agent package for Microsoft 365 Copilot.
Readme
@powellsoftware/connector-cli
Interactive scaffolding tool for Powell Connectors. A partner runs one
command, answers a few prompts, and gets a personalized declarative-agent
package (.zip) ready to upload to Microsoft 365 Copilot — backed by a connector
instance the CLI just created in the registry, with its GUID already stamped into
the MCP action URL.
npx @powellsoftware/connector-cliThink create-next-app, but the output is a Copilot agent wired to a connector
instance the CLI provisions for you.
Requirements
- Node.js 20.18.1 or later (uses ESM and native
fetchsemantics) - A Powell partner account, for anything other than mock mode
That is all you need. new reads the auth config id it stamps into a package
from the registry, along with everything else about your connector instance, so
there is nothing to provision and no flag to pass.
The CLI falls back, in order, to --auth-config-id <id>, to
POWELL_SHARED_AUTH_CONFIG_ID, and finally to provisioning one for you.
Provisioning creates the auth config in your own tenant — it has to, since
the config is registered as targetAudience: HomeTenant and cannot be shared
across tenants. It needs the Microsoft 365 Agents Toolkit CLI on PATH:
npm install -g @microsoft/m365agentstoolkit-cliThe provisioning project ships with this package, so there is nothing to clone.
Install
Nothing to install — run it straight from npm:
npx @powellsoftware/connector-cli # guided flow (the `new` command)
npx @powellsoftware/connector-cli --helpOr install it globally if you use it often:
npm install -g @powellsoftware/connector-cli
powell-connector newTry it offline (mock mode)
Every command runs end to end against a local fake registry and the golden template bundled in this package — no backend, no sign-in:
npx @powellsoftware/connector-cli new --mockMock mode allocates a real GUID, writes an instance row to a JSON file under your
OS config directory, and produces a genuine, sideloadable .zip. Its catalogue
mirrors the real one, expected configuration included, so the prompts are the
prompts you get in production. Set POWELL_REGISTRY_MOCK=1 instead of passing
--mock if you prefer.
Commands
new — create a connector and download its package
The default command: npx @powellsoftware/connector-cli and
npx @powellsoftware/connector-cli new are the same thing.
It asks for an agent name, one or more connectors picked from the catalogue
the registry advertises (GET /api/connectors), whatever
configuration those connectors expect
(GET /api/connectors/{id}/expectedconfig), a language and optional branding.
Then it creates a connector instance per connector
(POST /api/connectorinstances), takes the GUIDs the registry allocated, stamps
them and the branding into the golden template, and writes the .zip to the
current directory.
No target tenant is asked for: a package is not bound to one. Every package
references the shared auth config Powell provisions once, in its own tenant, for
any Microsoft 365 organization (see
Plugin authentication), so the same .zip
installs in any tenant that has consented to the Entra application.
Interactively that is a guided flow: a full-screen application with a step
tracker down the side, rather than a sequence of prompts. It exists because the
answers depend on things a prompt cannot show while it is asking — what a
connector will want configured before you pick it, how many tools an agent may
publish while you are ticking them. esc goes back a step; nothing is created until the
review screen is confirmed.
Supplying --name and either --connector or --tool answers
everything and skips the flow entirely, so the same command works in CI with no
terminal attached. The two paths do the same work in the same order and produce
the same package; only the guided flow can put several connectors on one agent,
since that is a decision taken by ticking rows.
# guided
npx @powellsoftware/connector-cli new
# fully non-interactive
npx @powellsoftware/connector-cli new \
--name "Contoso HR" \
--connector Zendesk \
--config subdomain=contoso \
--config clientId=8f14e45f \
--config clientSecret=s3cret \
--config sandbox=false \
--accent-color "#C4302B" \
--publisher "Contoso Ltd" \
--publisher-url https://www.contoso.com \
--out ./packages| Flag | Prompt | Notes |
| --- | --- | --- |
| --name <name> | Agent name | 3–30 chars, starts with a letter. Also the connector instance name. Required for non-interactive runs. |
| --connector <idOrName> | Connector | A row of the connector catalogue, by GUID or display name. Required for non-interactive runs, unless --tool is used. |
| --tool <selector> | Tools | À la carte: publish just this tool. Repeat once per tool. Its presence picks à la carte creation, so it replaces --connector. See À la carte. |
| --config <key>=<value> | one prompt per expected field | Repeat once per field. See Connector configuration. |
| --config-file <path> | as above | JSON object of the same key/value pairs. --config overrides individual keys. |
| --customer <guid> | — | Owning customer: a customer id, or the tenant's Azure id, which the server resolves. Omitted, the instance is available to all customers. |
| --description <text> | Short description | Defaults to a generated one-liner. |
| --accent-color <hex> | Accent colour | #RGB or #RRGGBB. Defaults to the Powell blue #0078A8. |
| --publisher <name> | Publisher name | Defaults to Powell Software. |
| --publisher-url <url> | Publisher website | https only. Privacy and terms URLs are derived from it. |
| --language <en\|fr\|de> | Language | What language this agent talks to its own end users in: translates its conversation starters, and is stored on the instance so flexdesk/orgchart fall back to it too. Defaults to en. |
| --surface <agent\|cowork\|both> | Copilot | Which Copilot the package is for: a declarative agent for Copilot Chat (agent, the default), a Copilot Cowork plugin (cowork), or both zips from the same instances. |
| -o, --out <dir> | — | Output directory. Defaults to the current directory. |
Prompts are skipped entirely once --name and either --connector or at
least one --tool are present, which is what makes the
command usable in CI — and in that mode
every field the connector expects must also arrive by flag, or the run fails
naming the ones that are missing. The last line of stdout is just the generated
file path, so it can be captured:
ZIP=$(npx @powellsoftware/connector-cli new --name "Contoso HR" --connector GraphSearch | tail -1)À la carte: picking individual tools
Right after the agent is named, the guided flow asks how it should get its tools:
How should this agent get its tools?
❯ A connector everything one connector offers
À la carte pick individual tools, across connectorsA connector is the flow described everywhere else in this document. À la carte instead picks tools individually, in two steps — groups first, then the tools inside them. Both lists scroll rather than printing everything, and the keys are shown on the prompt:
Which groups of tools? · ↑↓ move · space toggle · a all · enter confirm
◼ FlexDesk 13 tools
◼ GraphSearch 8 tools
◻ Zendesk 21 tools
Which tools? (all 21 selected - unselect what this agent should not publish) · ↑↓ move · space …
◼ FlexDesk/List desks GET /api/odata/FlexDesk/Desks
◼ FlexDesk/Book a desk POST /api/flexdesk/bookings
◻ FlexDesk/Cancel a booking DELETE /api/flexdesk/bookings/{id}
◼ GraphSearch/Find people GET /api/graph/peopleEverything in the groups you chose starts selected, so the common case — "these two groups, minus a couple of write tools" — is a few keystrokes. When the groups hold more tools than the agent may publish, nothing starts selected and the prompt says how many you can take.
Then you are asked only for the configuration the tools you picked actually
need. That set is worked out per run: each picked tool's endpoint group is
matched back to the connector that owns it, and those connectors' expected
fields are unioned. Pick a FlexDesk tool and you are asked for SharePointUrl;
pick only org-chart tools and you are asked for nothing.
Configuration needed by the tools you picked (from FlexDesk): SharePointUrl, apiKey, maxResultsNothing has to be copied onto the carrier row for this — the connector rows already declare what they need, and the carrier stays empty. A field two connectors both ask for is asked once.
Non-interactively, --tool says the same thing and is what selects the mode.
A selector is METHOD /path, a group name for all of its tools, or a pattern
with *:
npx @powellsoftware/connector-cli new \
--name "Contoso Workplace" \
--tool "GET /api/odata/FlexDesk/Desks" \
--tool OrgChart \
--tool "GET /api/graph/*"A selector matching nothing fails the run, naming the groups you can pick from —
an agent quietly missing a tool you asked for is worse than a failed command.
Note that a group the server hosts but the carrier does not allow is not
pickable, and is reported that way: the server would refuse it at tools/list,
so offering it would only produce an agent that publishes nothing.
This is still one connector instance and one action. The picked tools are
written into the instance's own configuration (endpointGroups, toolFilter,
maxTools), which the server layers over the connector's, so the package ships
one ai-plugin.json pointing at one /mcp/{instanceId} — one auth config, one
consent. That is the difference from several connectors in one
agent below, which is the other way to widen
an agent and costs an action, an ai-plugin and a namespace per connector.
Two consequences worth knowing:
- The instance can only ever narrow what its connector allows. Writing a
broader filter does not reach a tool the connector excluded. The picker only
offers the carrier's groups, and refuses more tools than its
MaxToolsallows, so a package it produces is one the server will actually serve. - Tools are selected by
METHOD /path, never by name — a tool's published name is derived from the connector it hangs off when the endpoint pins none, so a selection by name would stop matching the moment the agent is renamed.
Setup, once per deployment: à la carte instances hang off a single catalogue row
named Powell A la carte, created by the SeedAlaCarteConnector migration
(dotnet ef database update). Its EndpointGroups is the list of groups agents
may compose from, so opening a new group to composition means an UPDATE on
that row. Without it the command stops and says so.
Several connectors in one agent
After the first connector is configured, the guided flow asks whether the agent
should expose another, and keeps asking until you decline or the catalogue runs
out. Not asked for an à la carte agent, whose point is one curated tool set
behind one action. Each one you add is a connector instance in its own right: its own
configuration answers, its own registry row, and its own
/mcp/{instanceId} endpoint.
In the package, each becomes an entry in declarativeAgent.json's actions,
backed by its own ai-plugin-<slug>.json. Copilot chooses between their tools by
description, so a connector added here widens what the agent can answer rather
than replacing anything.
Agent name Contoso Workplace
Connector Powell Governance
... its configuration ...
Add another connector to this agent? yes
Connector to add FlexDesk
... its configuration ...
Add another connector to this agent? no
-> contoso-workplace-1.0.1.zip
declarativeAgent.json two actions
ai-plugin-contoso-workplace.json -> /mcp/{governance instance}
ai-plugin-contoso-workplace-flexdesk.json -> /mcp/{flexdesk instance}Two things stay agent-wide and are not asked twice: the branding and the auth
config. One reference_id covers every action — the config
is scoped to the MCP host, whose targetUrlsShouldStartWith already covers
/mcp and therefore every connector under it.
The first connector remains the primary: its instance GUID is the Teams app id,
and the golden template is fetched for it. An agent built with a single connector
is unaffected — it still ships one action and one ai-plugin.json.
This is a guided-flow feature only. The flags describe one connector, and
download, which regenerates the package of one existing instance, is likewise
always single-connector.
Connector configuration
Each catalogue row declares the fields its instances need, as a list of
{ name, fieldType } pairs. The CLI asks for exactly those, and fieldType
decides how:
| fieldType contains | Prompt | Sent as |
| --- | --- | --- |
| pass, secret, token, key, credential | masked input | string |
| bool, checkbox, switch, toggle, flag | yes/no | boolean |
| number, int, long, decimal, float | validated number | number |
| url, uri, endpoint | validated URL (http or https) | string |
| anything else | free text | string |
The answers become the instance's configuration JSON — the same document the
MCP runtime reads back for that connector id. Keys the connector did not ask for
are kept and sent, but called out on the way through, so a typo in a field name
cannot pass unnoticed. A connector that expects nothing is created with an empty
configuration and no configuration prompts.
list — show your connector instances
npx @powellsoftware/connector-cli list
npx @powellsoftware/connector-cli list --all # include inactive connectors
npx @powellsoftware/connector-cli list --json # machine-readable| Flag | Notes |
| --- | --- |
| -a, --all | Include instances whose connector is not Active (hidden by default). |
| --json | Print the raw array instead of a table. Includes configuration values, secrets and all — the table deliberately does not. |
An instance has no status of its own: it is usable exactly as long as the
catalogue row it was created from is active, so STATUS shows that row's status.
CONFIG lists field names only — configuration values routinely hold secrets.
Aliased as ls.
download <instanceId> — regenerate a package
Re-stamps the package for an existing connector instance. The instance and its GUID are reused, so an agent already deployed in a tenant keeps working; only the package is rebuilt.
Branding is not stored server-side, so it comes from the same flags as new and
otherwise falls back to the Powell theme.
npx @powellsoftware/connector-cli download 19737c86-b73e-4e14-bf61-20e4254c6b74 --out ./packages| Flag | Notes |
| --- | --- |
| -o, --out <dir> | Output directory. Defaults to the current directory. |
| -q, --quiet | Print only the generated file path. |
| --accent-color, --publisher, --publisher-url | Branding, as in new. |
| --language <en\|fr\|de> | Language, as in new. Unlike branding this one IS stored server-side, so omitting the flag reuses the value new set; passing it pushes an update. |
| --surface <agent\|cowork\|both> | As in new. Not stored: pass cowork to build the Cowork plugin of an instance created for an agent. |
update-config <instanceId> — push the connector's UI configuration
Partners customize how a connector's widgets look and behave in a JSON file they keep beside their code, and push it to the MCP server with this command. The settings are stored per connector instance — the GUID in the deployed package, the one the MCP runtime resolves a request by — so two tenants running the same connector each get their own look.
# once: scaffold a file listing every option, at its current platform default
npx @powellsoftware/connector-cli update-config <instanceId> --init
# edit powell-connector.config.json, then
npx @powellsoftware/connector-cli update-config <instanceId> --dry-run # see the diff
npx @powellsoftware/connector-cli update-config <instanceId> --yes # push it| Flag | Notes |
| --- | --- |
| -f, --file <path> | Configuration file. Defaults to ./powell-connector.config.json. |
| --init | Write a starter file instead of pushing. Refuses to clobber an existing one without --force. |
| --force | With --init, overwrite the file. |
| --merge | Apply only what the file sets, leaving the rest of the stored configuration alone. |
| --reset | Clear the stored configuration, back to the platform defaults. Confirms first. |
| --show | Print the stored configuration and exit. Pipeable. |
| --dry-run | Validate and show the diff, push nothing. |
| --json | Machine-readable output. |
| -y, --yes | Skip the confirmation prompt (required in CI). |
Aliased as config.
A push replaces the stored document; --merge layers onto it. Replacing is
the default because it is what makes the file the single source of truth:
deleting a property from the file hands it back to the platform default, instead
of leaving the last value it ever had stranded in the database. Dropping a
setting that way is the one case the command confirms before proceeding.
The diff is about what changes on screen, not about what changes in the document. A file that spells out a value the platform already defaults to is stored, but reported as no visible change — otherwise the four edits that matter would be buried under sixteen that do nothing.
Values are canonicalized before they are sent ("RADIAL" → radial, #f30 →
#FF3300), matching what the server stores, so a second push of an unchanged
file reports nothing to do rather than a permanent phantom diff.
The configuration file
JSON, with // comments and a trailing comma tolerated — which is what lets
--init scaffold a file that documents its own options inline. Comments never
leave your machine; what is pushed is the parsed document.
{
"version": 1,
// The connector's fixed language: what `new`/`download` write conversation
// starters in, and a widget's fallback locale when it has none of its own.
// One of: en | fr | de
"language": "fr",
"orgChart": {
// One of: verticalTree | horizontalTree | radial | compactList
"layoutStyle": "radial",
"visibleFields": ["name", "title"], // hides email, phone, ...
"quickActions": ["sendTeamsMessage", "bookMeeting"],
"theme": { "accentColor": "#0078A8", "cornerRadius": 8 },
"colorScheme": "dark",
"locale": "fr-FR"
},
"flexDesk": {
// A floor plan is read by colour first, so brand these before anything else
"statusColors": { "available": "#107C41", "booked": "#C4314B", "unavailable": "#8A8886" },
"showDeskLabels": "hover", // readable on a dense floor
"visibleFields": ["name", "area", "status"], // hides who is sitting there
"theme": { "accentColor": "#0078A8", "cornerRadius": 8 },
"locale": "fr-FR"
}
}One section per widget - orgChart for the organization chart, flexDesk for
the desk booking floor plan. Set one, both, or neither; theme, colorScheme,
locale, reducedMotion and direction mean the same thing in each.
language sits above both sections because it is not presentation for one
widget - it is also what new/download bake into the package at generation
time. A widget's own locale (BCP-47, e.g. fr-CA) still wins when set: it
exists for formatting nuance within one language. language is the mono-tenant
default underneath it - what the connector talks in when nothing more specific
says otherwise.
Every property is optional, and omitting one means "use the platform default".
--init writes the full list with an explanation and the accepted values above
each one:
orgChart
| Property | Values |
| --- | --- |
| layoutStyle | verticalTree (indented), horizontalTree (top-down), radial, compactList |
| nodeDensity | compact, comfortable, fitToWindow (scales the chart to the frame) |
| showPhotos, avatarFallback | true/false; initials or icon when there is no photo |
| groupBy | none, department, office |
| highlightSelf | Rings the signed-in user's node and chips it "You" |
| visibleFields | Any of name, title, department, email, phone, office, in render order |
| quickActions | Any of sendTeamsMessage, viewProfile, bookMeeting, sendEmail, in display order; [] for none |
| enableExpandCollapse | Off, the whole loaded tree is drawn and the toggles disappear |
| theme | accentColor, logoUrl, cornerRadius (0-32), fontFamily |
| maxNodesRendered | 1-5000; past it the widget paginates rather than truncating silently |
| emptyStateMessage | Shown when the chart cannot be resolved |
| colorScheme | light, dark, system |
| locale | BCP-47 tag, or "" to follow the host. Drives formatting and the built-in strings |
| reducedMotion | Disables the expand/collapse animation |
| direction | ltr, rtl, auto |
flexDesk
| Property | Values |
| --- | --- |
| statusColors | available, booked, unavailable as hex - the fill of each desk on the plan |
| theme | accentColor, logoUrl, cornerRadius (0-32), fontFamily |
| floorPlanBaseUrl | https origin a plan image may load from. "" draws the plan as SVG only, which is the self-contained default |
| showAreaLabels | Draws each zone's name above its desks |
| showDeskLabels | always, hover, never |
| showLegend | The status and zone chips under the plan, which are also its filters |
| highlightSelf | Rings the desk the signed-in user holds and chips their booking "You" |
| enableBooking | Off, the plan is read-only: no book and no release action |
| visibleFields | Any of name, area, status, occupant, description, capacity, in display order |
| maxDesksRendered | 1-2000; past it the plan notes how many desks it left out |
| emptyStateMessage | Shown when no plan can be resolved |
| colorScheme | light, dark, system |
| locale | BCP-47 tag, or "" to follow the host |
| reducedMotion | Disables the selection and filter transitions |
| direction | ltr, rtl, auto. Mirrors the chrome, never the plan - a floor does not mirror |
A setting takes effect on the next chart or plan the agent draws - the server sends the connector's configuration with every tool result, so there is nothing to redeploy and no cache to bust.
showPhotos is the one setting with nothing behind it yet: the org chart payload
carries no photo URL, so avatars always land on avatarFallback.
The document is validated on both sides. The CLI checks it before spending a
round trip — so --dry-run is worth running with no connection at all — and the
server checks it again and has the last word. Either way an unknown property is
an error naming the property, never a setting that silently does nothing, and a
file with four problems is rejected once, listing all four.
auth — provision an agent's auth config ahead of time
powell-connector auth provision --agent "Contoso HR" # `new` does this for you
powell-connector auth show # print the id the CLI would userevoke <instanceId> — take an instance out of service
Deletes the connector instance. The registry has no "revoked" state for an
instance, so removing the row is what takes it out of service: the MCP endpoint
stops resolving that GUID. Any package already deployed with it stops
working, so the command confirms first. Aliased as delete.
npx @powellsoftware/connector-cli revoke 19737c86-b73e-4e14-bf61-20e4254c6b74
npx @powellsoftware/connector-cli revoke <id> --yes # skip the confirmation| Flag | Notes |
| --- | --- |
| -y, --yes | Skip the confirmation prompt (required in CI). |
Global flags
Accepted before or after the command name:
| Flag | Notes |
| --- | --- |
| --mock | Use the local fake registry and bundled template. |
| --lang <en\|fr\|de> | Display language of the CLI itself (prompts, --help, messages) — not to be confused with new/download's --language, which is the language the generated agent talks to its own users in. Defaults to your system's locale, and is remembered after the first explicit use. |
| -v, --version | Print the CLI version. |
| -h, --help | Help for the program or for a command. |
Environment variables
Read from the process environment, or from a .env file in the working
directory. Real environment variables win over the file. See
.env.example.
POWELL_CONNECTORS_URL is the single base URL the CLI talks to: it is both the
registry API host and the host used to build the MCP action URL stamped into
ai-plugin.json. Unset, it falls back to the Powell-hosted
https://connectors.powell-software.com (the default in
src/config.ts); set it to point the CLI at another deployment,
such as a tunnel or a staging ring. --mock bypasses the network entirely.
| Variable | Default | Purpose |
| --- | --- | --- |
| POWELL_REGISTRY_MOCK | 0 | 1 is the same as --mock. |
| POWELL_REGISTRY_TIMEOUT_MS | 30000 | Per-request timeout. |
| POWELL_CONNECTORS_URL | https://connectors.powell-software.com | Base URL of the registry API and of the MCP action URL. |
| POWELL_AUTH_AUTHORITY | Powell's multi-tenant authority | Override the OAuth authority base. Not needed for normal use. |
| POWELL_AUTH_CLIENT_ID | Powell's public client | Override the device-code client id. Not needed for normal use. |
| POWELL_AUTH_SCOPE | openid profile offline_access | Scopes requested for the registry API. |
| POWELL_AUTH_DEVICE_CODE_PATH | /devicecode | Path appended to the authority. |
| POWELL_AUTH_TOKEN_PATH | /token | Path appended to the authority. |
| POWELL_AUTH_NO_BROWSER | 0 | 1 prints the verification URL instead of opening a browser. |
| POWELL_SHARED_AUTH_CONFIG_ID | — | Shared auth config id stamped as reference_id. |
| POWELL_CLI_HOME | OS config dir | Where the token cache, mock registry state and known auth config ids live. |
| POWELL_DEBUG | — | 1 also prints the underlying cause of handled errors. |
| POWELL_PLAIN | — | 1 turns off the ruled tables and the animated spinner, leaving plain text. |
Authentication
The CLI signs the partner in with the OAuth 2.0 device authorization grant
(RFC 8628) — the same experience as az login or the M365 Agents Toolkit:
- The CLI asks the authority for a device code.
- It prints a short code and opens the browser at the verification URL.
- It polls the token endpoint until sign-in completes.
- The token is cached under
POWELL_CLI_HOME(mode0600), keyed by authority + client id + scope, and silently refreshed while the refresh token lasts.
Everything sits behind the AuthProvider abstraction
(src/auth/provider.ts), so the registry client only
ever asks for an Authorization header. Swapping in client credentials or a CI
token is a one-class change.
The endpoints are still stubs. They are configuration, not constants: point
POWELL_AUTH_AUTHORITY and POWELL_AUTH_CLIENT_ID at the real Entra app
registration and the flow goes live with no code change. Until then, --mock
uses MockAuthProvider and skips sign-in entirely.
Sign-in is not optional against the live registry: /api/connectors and
/api/connectorinstances are protected paths on the MCP server
(AuthenticationCheckMiddleware), which requires an Authorization header
carrying an unexpired JWT whose appid/aud is listed in the server's
Authentication:AllowedEntraAdAppIds. Configure POWELL_AUTH_SCOPE so the token
the CLI gets carries one of those.
Customer entitlement
The platform is for Powell customers, and a customer is a row in the server's
Customers table keyed by its Entra tenant id (TenantAzureId). The CLI checks
that before it does anything: every command that talks to the registry — new,
list, download, update-config, revoke — reads the tid claim of the
signed-in partner's token and refuses to continue unless a customer owns that
tenant. The claim comes from the token, so it is not something a caller can set.
Error You are signed in to tenant aaaabbbb-0000-cccc-1111-dddd2222eeee is not a Powell customer.
Hint This CLI is for Powell customers: the tenant you sign in with must be registered as one.An inactive customer (IsActive = 0) is refused the same way, with its own
message. Both refusals exit 3.
There is no check on the tenant a package is for, because a package is not
bound to one: it references the shared auth config, registered for any Microsoft
365 organization. new used to ask for a target tenant; it was only ever printed
in the summary, and the flag (--tenant) is gone.
The lookup is GET /api/customers/by-tenant/{tenantAzureId}
(CustomersController), which answers 404 when no customer owns the tenant. It
is read-only, carries no contact details or tokens, and is not published as an
MCP tool. The tenant is looked up at most once per run.
A server too old to have that route also answers 404, so the two are told apart
by the body: the controller writes its own { success: false, ... } envelope,
while an unmatched route answers empty. An empty 404 is reported as "this
server does not support the customer check" and points at the deployment —
never as "you are not a customer", which would lock every partner out of a
deployment that had simply not been updated yet.
Under --mock the check still runs, against the fake registry in
src/registry/mock-client.ts: the mock partner's
tenant is a customer, and any other tenant is refused — so the refusal path is
reproducible offline.
Plugin authentication (auth config)
ai-plugin.json points the agent at the MCP server through a runtime auth block:
"auth": { "type": "OAuthPluginVault", "reference_id": "<this agent's auth config id>" }reference_id is an auth config id — the id of a record in the Microsoft
Enterprise token store that Copilot uses to obtain and refresh tokens. It is not
an Entra application (client) id, even though both are GUIDs.
One auth config per agent, created by new and consented on that agent's
first sign-in. Connectors are still told apart by the connector_id in the MCP
URL path — the auth config governs authentication, not routing:
| Varies per agent | Identical in every package |
| --- | --- |
| runtimes[].spec.url → {POWELL_CONNECTORS_URL}/mcp/{connector_id} | runtimes[].auth.type = OAuthPluginVault |
| manifest.json id, name, description, branding | |
| runtimes[].auth.reference_id (one per agent) | |
The On-Behalf-Of exchange to Graph or SharePoint is a server-side concern and has nothing to do with this inbound auth config.
Dynamic client registration, per agent
The auth config is created by the toolkit's dcr/register action: the token
store reads the MCP server's /.well-known/oauth-authorization-server, follows
the registration_endpoint it finds there, and registers a client.
Host consistency is what makes this work. That registration endpoint has to be
on the same host the agent actually calls. A document served from host A whose
registration_endpoint points at host B provisions cleanly and then fails at
sign-in, with nothing to go on. MCP_BASE_URL in the toolkit environment drives
both the well-known URL and the targetUrlsShouldStartWith prefix, so the two
cannot drift apart.
Two consequences worth knowing:
targetUrlsShouldStartWithis required, with exactly one entry — the token store rejects zero with "The configuration must contain exactly one targetUrlsShouldStartWith entry", which the yaml schema does not say. It is set to{MCP_BASE_URL}/mcp, covering every connector under that path.- A config is pinned to the host it was provisioned against, so moving from a dev tunnel to production means re-provisioning each agent.
DCR configurations live in the token store's dynamicConfigurations collection,
which has no read API and which the Teams developer portal does not list. Their
absence from the portal's OAuth client registration page is expected, not a
failure — that page is backed by oAuthConfigurations, which only
oauth/register writes to.
Static, shared auth config (auth provision --static)
Microsoft does not support DCR for a server protected by Microsoft Entra ID, and a
DCR config cannot be read, so nobody can check which scope it requests. Without
offline_access Entra issues no refresh token. The access token then expires after
about an hour: a conversation asks the user to sign in again, and a Copilot Cowork
scheduled prompt, which has nobody to sign in, runs without the connector's tools.
--static runs the auth-provisioning/static/
project instead: one oauth/register config for every agent and plugin on the MCP
host, with the scope stated (api://<client-id>/access_as_user offline_access by
default; offline_access is always added to --scope). It is listed in the Teams
developer portal under Tools › OAuth client registration.
This is a Powell operation, done once per host — not something partners run. The
config is registered for any Microsoft 365 organization (targetAudience: AnyTenant),
so the client secret stays in the Enterprise token store and partners only ever get
the config id, which is not a secret. Each customer tenant still consents to the
Entra application on first sign-in (or through an admin consent).
Once, on the Entra application that protects the server:
- add the Web redirect URI
https://teams.microsoft.com/api/platform/v1.0/oAuthRedirect; - create a client secret, and put it in
env/.env.shared.userof the static project asSECRET_OAUTH_CLIENT_SECRET(copyenv/.env.shared.user.example; the file is git-ignored, and the secret is never taken on the command line).
Then:
powell-connector auth provision --static --client-id <entra app id>The run prints the config id. Publish it on the server of that host as
PublicServer:SharedAuthConfigId (appsettings, or the PublicServer__SharedAuthConfigId
environment variable) and restart it: the registry then serves it to every CLI run, so
partners set nothing. new provisions no DCR config, needs no Agents Toolkit and opens
no browser, and download stamps it into regenerated packages.
POWELL_SHARED_AUTH_CONFIG_ID or --auth-config-id still override it for one run.
A package built on an older DCR id keeps working until it is regenerated.
When the client secret nears its expiry, replace it in the registration (developer
portal, or the toolkit's oauth/update): the config id stays the same, so no package
has to change. Letting it expire breaks token refresh for every user on the host.
How the auth config is created
new provisions it, using the agent name you gave it:
powell-connector new --name "Contoso HR"After the connector instance exists, the CLI drives the Agents Toolkit over the
auth-provisioning/ project shipped with this package, with --agent set to
that name. The
toolkit environment is named after the agent (env/.env.contoso-hr), so
re-running new for the same name reuses that agent's config rather than
creating a second: dcr/register skips creation when the environment already
holds AGENT_AUTH_CONFIG_ID.
A browser opens for sign-in and consent — that is what creates the config in
your own tenant, which is why it cannot be handed to you ready-made: the
project registers it with targetAudience: HomeTenant, so a config is confined
to the tenant that created it.
The toolkit must be on PATH (npm install -g @microsoft/m365agentstoolkit-cli);
its absence is checked before the first prompt, so it does not surface after
you have answered everything. The project itself needs no checkout: the CLI
copies the bundled one into its config directory the first time it runs, and
writes the per-agent environments there.
| Flag | Effect |
| --- | --- |
| --auth-config-id <id> | Use this id; skip provisioning entirely |
| --skip-auth | Provision nothing; leave the template reference_id untouched |
| --auth-project <dir> | Point at a different auth-provisioning directory |
| --auth-timeout <seconds> | Budget for the provisioning run (default 900) |
| --ci | Non-interactive; the toolkit must already be signed in |
powell-connector auth provision --agent "<name>" still exists for provisioning
ahead of time, or for an agent whose package you are not building right now.
Which id ends up in a package
--auth-config-id, thenPOWELL_SHARED_AUTH_CONFIG_ID, then the deployment's shared config (GET /api/connectorinstances/auth-config, set on the server asPublicServer:SharedAuthConfigId), then whatever the registry reports for the instance — all deliberate, all skip provisioning (and the Agents Toolkit check);- otherwise, the config provisioned for this agent.
download follows the same order, and puts the deployment's shared config ahead of
the DCR id recorded for the instance on this machine: publishing a shared config is
how existing agents move onto it, at their next regeneration.
There is deliberately no "last id used" fallback in new. With one config per
agent that value belongs to whichever agent was provisioned most recently, and
stamping another agent's config is exactly the failure this design avoids.
download never provisions — regenerating a package must not create tenant
resources. It uses the id recorded for that instance, or --auth-config-id, and
warns when it has neither.
How the package is generated
The "golden template" is a declarative-agent app package:
appPackage/
manifest.json Teams app manifest
declarativeAgent.json the agent definition
ai-plugin.json the MCP runtime binding
instruction.txt system prompt, referenced by declarativeAgent.json
color.png / outline.png store iconsFor each generation the CLI:
Asks the registry for the template zip, and falls back to
templates/golden/— the copy bundled with this CLI — when the registry does not serve one (which it does not yet).Strips the wrapper folder, so
manifest.jsonends up at the zip root — required for sideloading.Stamps the connector into the JSON documents:
| File | Field | Value | | --- | --- | --- | |
manifest.json|id| the connector instance GUID | | |version| bumped on every generation | | |name.short/name.full| connector name | | |description.*| connector description | | |accentColor| theme accent colour | | |developer.*| publisher name, website, derived privacy/terms URLs | | |validDomains| host of the action URL | |declarativeAgent.json|name,description| connector name / description | |ai-plugin.json|runtimes[].spec.url|{POWELL_CONNECTORS_URL}/mcp/{guid}| | |runtimes[].auth.reference_id| this agent's auth config id | | |namespace,name_for_human| derived from the connector name |Re-zips and writes
<connector-slug>-<version>.zip.
Unknown fields are preserved, and values are clamped to the Teams/Copilot schema length limits so the result validates. The version is the golden template's own, with its patch segment bumped — so generation is reproducible: the same instance and the same template always produce the same file name.
Copilot Cowork plugin
With --surface cowork (or Copilot → Cowork on the options screen) the CLI
writes <connector-slug>-cowork-<version>.zip instead: a
Cowork plugin,
which is what Copilot Cowork loads — it does not run declarative agents.
both writes the two zips.
manifest.json Microsoft 365 manifest v1.28: agentConnectors + agentSkills
tools/<connector-slug>.json the instance's tools/list, as the upload requires (non-empty)
skills/<agent-slug>/SKILL.md the agent's instructions, as a skill
color.png / outline.png the template's icons- Nothing changes on the server. Each
agentConnectors[]entry points at the same/mcp/{instanceId}with the same auth config (OAuthPluginVault,referenceId) as the agent, and Cowork discovers the tools withtools/listat runtime — so the instance's tool selection applies to Cowork exactly as it does to the agent. - The tool description file is a snapshot. The upload refuses a connector whose
mcpToolDescriptionhas an emptytoolsarray, so the CLI callstools/liston each instance's/mcp/{instanceId}while it builds the plugin and writes the answer (without the widget_meta). An instance publishing nothing fails the run. Tools changed later still reach Cowork; regenerate to refresh the listing. - The skill is written from the connector row. Its body is the
Instructionscolumn; itsdescription(what Cowork reads to decide the skill applies) is the connector description followed by the conversation starters of the chosen language, cut to fit 1,024 characters. The folder andnameare the agent's slug. validDomainslists the widget asset hosts. Cowork strips any widget CSP origin the plugin'svalidDomainsdoes not cover. So next to the MCP host the CLI adds the hosts oforgChart.theme.logoUrl,flexDesk.theme.logoUrlandflexDesk.floorPlanBaseUrlfrom each instance's effective UI configuration — the settings the server declares in the widgets' CSP. After changing one of them withupdate-config, regenerate the plugin.- The app id is not the instance GUID — the agent package already uses that —
but a UUID v5 derived from it, so
downloadregenerates the same id and an upload updates the plugin rather than adding a second one. - To give Cowork only some tools, create a separate à la carte instance with
--surface coworkand the--tools it should publish.bothshares one instance, and so one tool set, between the agent and the plugin.
Upload the plugin in the Microsoft 365 admin center (Agents › Upload), or for
yourself with atk install --file-path <zip> --scope Personal, then enable it in
Cowork › Sources & Skills › Plugins and sign in once. Tools that are not
read-only ask for confirmation in Cowork, from the readOnlyHint /
destructiveHint annotations the server already publishes.
Exit codes
| Code | Meaning |
| --- | --- |
| 0 | Success |
| 1 | Generic handled failure |
| 2 | Invalid input (bad flag value, or a prompt needed with no TTY) |
| 3 | Authentication failure or sign-in not configured |
| 4 | Network failure (registry unreachable, timeout, TLS) |
| 5 | Registry returned an error (the status is in the error code) |
| 6 | Toolkit missing, auth provisioning failed, or no auth config id available |
| 70 | Unexpected internal error — please report it |
| 130 | Cancelled (Ctrl+C, or a declined confirmation) |
Handled failures print a one-line message plus a hint, never a stack trace. Set
POWELL_DEBUG=1 to also see the underlying cause.
