add-teams: ask before wiring the detected owner — a no defers with manual-wire guidance

The identity note ("stop here (Ctrl-C) if that's not you") becomes a real
decision: an operator note names the detected account ({{owner_upn}}), then
nc:prompt wire_owner (^(yes|no)$ — renders as a select under the driver's
enum-prompt rendering) gates the whole DM-open chain via when:wire_owner=yes.
wire_owner is only prompted on fresh creates, and an unbound var fails any
when: guard, so drop-through re-runs skip the chain exactly as before.

On no: a note explains how to wire the desired Teams user afterwards (DM-first
path needs no IDs; proactive wiring needs the person's Entra object ID and
where to find it), the wire inputs stay unresolved, and wireIfResolved falls
through to the deferred ending. Install/create/env still complete either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Koshkoshinsk
2026-07-07 14:06:33 +03:00
parent e7ecfb9955
commit 2ef9f11588
3 changed files with 95 additions and 28 deletions
+34 -17
View File
@@ -201,13 +201,34 @@ TEAMS_APP_TYPE=SingleTenant
### Who owns this bot
The account signed into the Teams CLI is the account that just created the
bot — that human is the owner the wiring step needs. Its identity comes from
the CLI session, so this runs before the sign-out step below:
bot — that human is the wiring target this flow suggests. Its identity comes
from the CLI session, so this runs before the sign-out step below:
```nc:run effect:fetch when:have_creds=no capture:owner_upn=.username,owner_aad_id=.userObjectId validate:^.+$
"$(npm prefix -g 2>/dev/null)/bin/teams" status --json 2>/dev/null
```
### Confirm the wiring target
Nothing is wired without a yes here. Show the human who was detected, then
ask — a no skips the whole [Link the bot to your account](#link-the-bot-to-your-account)
chain, setup finishes unwired, and the note below explains how to wire the
right person afterwards:
```nc:operator when:have_creds=no
Detected the account that created the bot: {{owner_upn}}. Wiring the assistant to it means its first message arrives in that account's Teams DMs.
```
```nc:prompt wire_owner when:have_creds=no validate:^(yes|no)$ normalize:lower
Wire the assistant to this account?
```
```nc:operator when:wire_owner=no
Setup will finish without wiring. To wire the right Teams user afterwards:
1. Easiest — no IDs needed: have that person DM the bot once in Teams ("hi" works). NanoClaw registers their identity and chat from that first message; then run /init-first-agent with your coding agent and pick them.
2. To wire proactively instead (the assistant messages them first), your coding agent needs that person's Microsoft Entra object ID — found at entra.microsoft.com > Users > (person) > Overview > Object ID, or Teams admin center > Manage users. Hand it to /manage-channels.
```
### Install the app in Teams
The app package is already uploaded — no manifest zip, no manual sideload.
@@ -223,7 +244,9 @@ Once the app shows up in your Teams sidebar (or app list), continue.
### Link the bot to your account
Nothing to do in Teams yet — these are background API calls. Same move as
Nothing to do in Teams yet — these are background API calls, and the whole
chain runs only after the yes in
[Confirm the wiring target](#confirm-the-wiring-target). Same move as
Slack's `conversations.open` and Discord's `users/@me/channels`:
create the bot↔owner 1:1 conversation proactively with the bot's own
credentials, so the assistant messages the human first — nobody has to DM the
@@ -233,7 +256,7 @@ below can fail once — re-running the skill is safe.
First a Bot Framework token from the app credentials:
```nc:run effect:fetch when:have_creds=no capture:bot_token validate:^eyJ
```nc:run effect:fetch when:wire_owner=yes capture:bot_token validate:^eyJ
curl -sf -X POST "https://login.microsoftonline.com/{{app_tenant_id}}/oauth2/v2.0/token" --data-urlencode "grant_type=client_credentials" --data-urlencode "client_id={{app_id}}" --data-urlencode "client_secret={{app_password}}" --data-urlencode "scope=https://api.botframework.com/.default" | jq -er '.access_token'
```
@@ -241,7 +264,7 @@ Create the 1:1 conversation (the AAD object id from the CLI session is a
valid member id; `smba.trafficmanager.net/teams` is the global service URL —
the same default the adapter itself uses):
```nc:run effect:fetch when:have_creds=no capture:conversation_id validate:^.+$
```nc:run effect:fetch when:wire_owner=yes capture:conversation_id validate:^.+$
curl -sf -X POST "https://smba.trafficmanager.net/teams/v3/conversations" -H "Authorization: Bearer {{bot_token}}" -H "Content-Type: application/json" -d '{"bot":{"id":"28:{{app_id}}"},"members":[{"id":"{{owner_aad_id}}","name":"","role":"user"}],"tenantId":"{{app_tenant_id}}","channelData":{"tenant":{"id":"{{app_tenant_id}}"}},"isGroup":false}' | jq -er '.id'
```
@@ -253,24 +276,17 @@ filter only guards against channels that list the bot itself (`28:` ids).
(Don't select by `.aadObjectId` here — the field is not reliably present in
this response and its GUID casing varies.)
```nc:run effect:fetch when:have_creds=no capture:owner_handle=.id,owner_name=.name validate:^.+$
```nc:run effect:fetch when:wire_owner=yes capture:owner_handle=.id,owner_name=.name validate:^.+$
curl -sf "https://smba.trafficmanager.net/teams/v3/conversations/{{conversation_id}}/members" -H "Authorization: Bearer {{bot_token}}" | jq -er '[.[] | select((.id // "") | startswith("28:") | not)][0] | {id, name: (.name // .givenName // "Teams user")}'
```
Compose the platform id exactly as the adapter encodes thread ids
(`teams:{b64url conversation}:{b64url service url}`):
```nc:run when:have_creds=no capture:platform_id validate:^teams:
```nc:run when:wire_owner=yes capture:platform_id validate:^teams:
node -e 'const c=process.argv[1];const s="https://smba.trafficmanager.net/teams/";console.log("teams:"+Buffer.from(c).toString("base64url")+":"+Buffer.from(s).toString("base64url"))' "{{conversation_id}}"
```
Tell the user who the wiring targets — if this isn't them, they should stop
here and wire manually with `/manage-channels` instead:
```nc:operator when:have_creds=no
The bot will be wired to {{owner_name}} ({{owner_upn}}) — the account that created it — and the assistant's first message will arrive in that account's Teams DMs. If that's not you, stop here (Ctrl-C) and wire manually with /manage-channels later.
```
### Sign out of the Teams CLI
The Microsoft 365 session was only needed to create the bot and identify its
@@ -309,9 +325,10 @@ skill outside the wizard? Run the same wire yourself:
pnpm exec tsx scripts/init-first-agent.ts --channel teams --user-id "teams:<owner_handle>" --platform-id "<platform_id>" --display-name "<the human's name>" --agent-name "<assistant name>" --role owner
```
**Fallback (re-runs, or the link step failed):** with credentials already in
`.env` the resolve steps are skipped, so there is nothing new to wire — the
first run's wiring still stands. If the install was never wired at all, the
**Fallback (re-runs, a no at the wiring confirm, or the link step failed):**
with credentials already in `.env` the resolve steps are skipped, so there is
nothing new to wire — the first run's wiring still stands. If the install was
never wired at all — including a deliberate no at the confirm — the
DM-first path always works: DM the bot once ("hi" is fine) — the router
auto-creates the messaging group row in `data/v2.db` from that first inbound
— then run `/init-first-agent` (or `/manage-channels`) with your coding
+12 -9
View File
@@ -29,18 +29,20 @@ function operatorBody(md: string, n: number): string {
describe('gatePolicy — §5.1 parity table (real skills)', () => {
it('teams: one gate — the install-in-Teams operator pauses before the DM-open fetches', () => {
// Operators in order (CLI-first flow): prerequisites, install-in-Teams
// (when:have_creds=no), the wiring-identity note. The finish-wiring
// handoff is prose only — the wizard wires inline from the resolved vars.
// Operators in order (CLI-first flow): prerequisites, the detected-owner
// note, the wire-declined note (when:wire_owner=no), install-in-Teams.
// The finish-wiring handoff is prose only — the wizard wires inline from
// the resolved vars.
const d = decisions(loadSkill('teams'));
expect(d).toHaveLength(3);
expect(d).toHaveLength(4);
expect(d.map((g) => g.needsConfirm)).toEqual([
false, // prereqs → prompt public_url (prompt is the barrier)
false, // detected-owner note → prompt wire_owner (prompt is the barrier)
false, // wire-declined note → install operator (last operator of the chain carries the barrier)
true, // install-in-Teams → the DM-open effect:fetch chain
false, // identity note → prompt signout (prompt is the barrier)
]);
// Completed-work flavor (fetch/external, not effect:step).
expect(d[1].flavor).toBe('completed');
expect(d[3].flavor).toBe('completed');
});
it('telegram: the pairing operator gains a readiness pause before the effect:step', () => {
@@ -140,10 +142,11 @@ describe('extractOfferUrl — §5.2 inventory', () => {
// The install block's URL is {{install_link}} in the AUTHORED body — no
// candidate matches here; the offer materializes at runtime from the
// rendered body (proven in run-channel-skill.test.ts's fresh-create case).
expect(operatorBody(md, 1)).toContain('{{install_link}}');
expect(operatorBody(md, 3)).toContain('{{install_link}}');
expect(extractOfferUrl(operatorBody(md, 0))).toBeUndefined(); // prereqs
expect(extractOfferUrl(operatorBody(md, 1))).toBeUndefined(); // install-in-Teams
expect(extractOfferUrl(operatorBody(md, 2))).toBeUndefined(); // wiring-identity note
expect(extractOfferUrl(operatorBody(md, 1))).toBeUndefined(); // detected-owner note
expect(extractOfferUrl(operatorBody(md, 2))).toBeUndefined(); // wire-declined note (entra link is schemeless on purpose)
expect(extractOfferUrl(operatorBody(md, 3))).toBeUndefined(); // install-in-Teams
});
it('slack — the <your-public-host> placeholder is EXCLUDED (normative negative fixture)', () => {
+49 -2
View File
@@ -225,7 +225,7 @@ describe('runChannelSkill adapter (Option A)', () => {
return { ok: true, fields: { STATUS: 'success' } };
},
resolveRemote: () => 'origin',
inputs: { public_url: 'https://acme.example', app_name: 'NanoClaw', signout: 'yes' },
inputs: { public_url: 'https://acme.example', app_name: 'NanoClaw', wire_owner: 'yes', signout: 'yes' },
confirm: async (m) => {
log.push(`confirm:${m}`);
return true;
@@ -245,7 +245,7 @@ describe('runChannelSkill adapter (Option A)', () => {
expect(log.some((c) => c.includes('--endpoint "https://acme.example/webhook/teams"'))).toBe(true);
expect(log.some((c) => c.includes('--name "NanoClaw"') && c.includes('--sign-in-audience myOrg'))).toBe(true);
// …the DM-open chain resolved the wire inputs: the owner's 29: id from the
// conversation members (selected by AAD id) and the adapter-encoded
// conversation members (first non-bot member) and the adapter-encoded
// platform id from the created conversation…
expect(res.vars.owner_handle).toBe('29:owner-xyz');
expect(res.vars.owner_name).toBe('Dan Mill');
@@ -273,6 +273,53 @@ describe('runChannelSkill adapter (Option A)', () => {
expect(fullyApplied(res)).toBe(true);
});
// A "no" at the wiring confirm skips the whole DM-open chain: no Bot
// Framework token is fetched, no conversation is created, and the wire
// inputs stay unresolved — the wizard's deferred (DM-first) ending takes
// over. The create/install/env half still completes.
it('Teams fresh create, wiring declined: the DM-open chain never runs and nothing resolves', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-decline-'));
mkdirSync(join(root, 'src/channels'), { recursive: true });
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
writeFileSync(join(root, '.env'), '');
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
const log: string[] = [];
const res = await runSkill('.claude/skills/add-teams', {
projectRoot: root,
exec: (c) => {
log.push(`exec:${c}`);
if (c.includes('TEAMS_APP_ID=.')) return 'no';
if (c.includes(' app create ')) {
return JSON.stringify({
teamsAppId: 'tapp-123',
installLink: 'https://teams.microsoft.com/l/app/tapp-123',
credentials: { CLIENT_ID: 'app-1', CLIENT_SECRET: 'a-much-longer-app-secret', TENANT_ID: 'tenant-1' },
});
}
if (c.includes('status --json')) {
return JSON.stringify({ loggedIn: true, username: 'dan@acme.example', userObjectId: 'aad-owner-1' });
}
},
execStream: async () => ({ ok: true, fields: { STATUS: 'success' } }),
resolveRemote: () => 'origin',
inputs: { public_url: 'https://acme.example', app_name: 'NanoClaw', wire_owner: 'no', signout: 'yes' },
confirm: async () => true,
openUrl: async () => {},
});
// The chain never started: no token fetch, no conversation create…
expect(log.some((c) => c.includes('login.microsoftonline.com'))).toBe(false);
expect(log.some((c) => c.includes('/v3/conversations'))).toBe(false);
// …the wire inputs stayed unresolved (wireIfResolved will defer)…
expect(res.vars.owner_handle).toBeUndefined();
expect(res.vars.platform_id).toBeUndefined();
// …while the credentials still landed and nothing bounced to an agent.
expect(readFileSync(join(root, '.env'), 'utf8')).toContain('TEAMS_APP_ID=app-1');
expect(res.agentTasks).toEqual([]);
expect(fullyApplied(res)).toBe(true);
});
// The resolved leg of wireIfResolved, driven with a minimal fixture skill
// (the real teams document needs a streaming exec runChannelSkill doesn't