teams-1on1-send-no-chat-read

star 5

Send a 1:1 Microsoft Teams chat message via Graph API even when the bearer token does NOT carry `Chat.Read*` scopes. Bypasses the list-chats-then-find-id pattern by deriving the 1:1 chat ID deterministically from sorted user OIDs in the canonical Teams form `19:{sortedOidLower1}_{sortedOidLower2}@unq.gbl.spaces`. Pairs naturally with browser-bearer auth where Conditional Access blocks Device Code Flow and the resulting tenant-scoped bearer carries `ChatMessage.Send` + `User.ReadBasic.All` but not `Chat.Read*`.

CarlosCaPe By CarlosCaPe schedule Updated 6/12/2026

name: teams-1on1-send-no-chat-read description: > Send a 1:1 Microsoft Teams chat message via Graph API even when the bearer token does NOT carry Chat.Read* scopes. Bypasses the list-chats-then-find-id pattern by deriving the 1:1 chat ID deterministically from sorted user OIDs in the canonical Teams form 19:{sortedOidLower1}_{sortedOidLower2}@unq.gbl.spaces. Pairs naturally with browser-bearer auth where Conditional Access blocks Device Code Flow and the resulting tenant-scoped bearer carries ChatMessage.Send

  • User.ReadBasic.All but not Chat.Read*. metadata: short-description: Send 1:1 Teams chat without Chat.Read scope (deterministic chat-id derivation) status: stable origin: empirical finding from one arm 2026-06-11 (two consecutive successful sends against the same recipient on a Chat.Read-less bearer; both hit the existing 1:1 thread, neither created a duplicate) promoted: 2026-06-12 — operator override of the 30-day soak rule because the pattern is theoretically grounded (canonical 1:1 chat-id format, documented across Microsoft community sources since Teams GA 2017) AND the empirical evidence is unambiguous (200 OK twice, no duplicate thread, no 404). Future arms inherit this immediately rather than re-discovering it.

1:1 Teams send without Chat.Read

When to use

You are sending a programmatic 1:1 Teams message and the bearer token carries:

  • ChatMessage.Send
  • User.ReadBasic.All (or any scope that resolves a recipient name → OID)
  • Chat.Read* (or Chat.ReadBasic)

Naively, the absence of Chat.Read* blocks sending: you cannot GET /me/chats?$filter=... to find the right chatId, so you have no target for POST /chats/{id}/messages. The bearer is "send-capable but discovery-blind."

This skill bypasses the discovery step entirely.

Why it works

The Graph API accepts a deterministically-constructed 1:1 chat ID and resolves the call against the existing 1:1 thread (or creates one on first contact). The canonical 1:1 chat ID format is:

19:{sortedOidLower1}_{sortedOidLower2}@unq.gbl.spaces

Where:

  • sortedOidLower{1,2} are the two participants' Object IDs (OIDs) — a GUID per user — sorted lexicographically and lowercased.
  • The 19: prefix and @unq.gbl.spaces suffix are constants for 1:1 chats in the public Teams cloud. (For sovereign clouds the suffix differs — check the tenant's environment.)

The two participants are: (a) the sender (the bearer's owner) and (b) the recipient. Both OIDs are obtainable from the bearer alone:

  • The sender OID is in the bearer JWT's oid claim.
  • The recipient OID comes from /me/people?$search=... or /users?$filter=mail eq '...' or userPrincipalName eq '...'. Both endpoints accept User.ReadBasic.All scope.

Once both OIDs are known, the chat ID is a pure string-construction operation — no Graph API call required for the chat-id step.

Pseudocode

function parseJwt(token) {
  // The bearer's payload is the middle segment, base64url-encoded JSON.
  const payload = token.split('.')[1];
  return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
}

function deriveOneOnOneChatId(myOid, theirOid) {
  // CRITICAL: lowercase BEFORE sorting. Reverse order can swap the pair.
  const a = myOid.toLowerCase();
  const b = theirOid.toLowerCase();
  const [first, second] = [a, b].sort();
  return `19:${first}_${second}@unq.gbl.spaces`;
}

async function send(token, theirOid, htmlBody) {
  const myOid = parseJwt(token).oid;
  const chatId = deriveOneOnOneChatId(myOid, theirOid);
  const url = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages`;
  const res = await fetch(url, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ body: { contentType: 'html', content: htmlBody } }),
  });
  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
  return res.json();
}

Recipient resolution (when you only have a name)

async function findUserOid(token, query) {
  const r = await fetch(
    `https://graph.microsoft.com/v1.0/me/people?$search=${encodeURIComponent(query)}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  const data = await r.json();
  const hit = (data.value || []).find((p) => p.scoredEmailAddresses?.length || p.userPrincipalName);
  if (hit?.id) return hit.id;
  // fallback to /users
  const r2 = await fetch(
    `https://graph.microsoft.com/v1.0/users?$filter=${encodeURIComponent(`displayName eq '${query}' or mail eq '${query}'`)}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  const d2 = await r2.json();
  return d2.value?.[0]?.id;
}

What this skill does NOT solve

  • Inbound discovery — listing chats, reading messages back, paginating through 1:1 history, inbox triage. All require Chat.Read* scopes that this skill explicitly works around the absence of.
  • Group chats — the deterministic format applies to 1:1 only. Group chat IDs are server-assigned and require Chat.Create / Chat.Read* flows.
  • Channel messages — channels live under teams, use a different Graph endpoint (/teams/{id}/channels/{id}/messages), and require ChannelMessage.Send + channel access scopes.
  • Sovereign / GCC / DoD clouds — the @unq.gbl.spaces suffix is public-cloud only. Confirm the environment-specific suffix from a known-good chat ID in that tenant before assuming.

Pair this with

  • Browser-bearer auth (browser-bearer-graph-auth skill) — the typical reason a bearer carries ChatMessage.Send but not Chat.Read* is that the bearer was captured from a Teams Web client that doesn't request Chat.Read* in its scope set. The browser-bearer pattern is itself a workaround for tenants where Conditional Access blocks Device Code Flow ("Block authentication flows" CA control). When both workarounds compose, you get a fully-functional outbound write surface on a tenant whose IT posture would otherwise block programmatic Teams integration.
  • PHI screening before send — if your tenant carries regulated content, screen the body BEFORE the POST. Strip URLs from the screening copy (URLs containing long digit sequences — page IDs, doc IDs, deeplink params — false-positive on phone-number / SSN regexes unless you strip them screen-side; the SENT body keeps URLs intact). Audit-log every send decision (allowed / blocked / dry-run) as NDJSON.

Anti-patterns

Anti-pattern Why it fails
Trying to use /me/chats?$filter=members/any(...) Returns 403 without Chat.Read*; the skill exists to skip this call
Computing chat ID without lowercasing OIDs Mixed-case OIDs produce a different chat ID than the canonical Teams clients use; the API may create a duplicate thread
Sorting OIDs after lowercasing inconsistently Sort + lowercase in fixed order: lowercase FIRST, then sort. Reverse order accidentally reorders the pair
Skipping the audit log Programmatic sends without an audit trail are indistinguishable from the user's manual sends in compliance review; log every send decision
Assuming @unq.gbl.spaces works in GCC/DoD Sovereign clouds use different suffixes; verify per environment
Using this for group chats Group chat IDs are server-assigned, not formula-derivable

Provenance

  • Empirical first observation: arm-local engagement, 2026-06-11. Two successful sends executed against the same recipient on a bearer that carried ChatMessage.Send + User.ReadBasic.All but explicitly did NOT carry Chat.Read* (verified via JWT scope decode). Both posts hit the existing 1:1 thread, not a new thread, not a 404.
  • Pattern-source: the canonical 1:1 chat ID format is documented across Microsoft community sources (stable across the public Teams cloud since the 2017 GA release). What this skill contributes is the framing — using the format as a Chat.Read-substitute for outbound send, rather than as an optimization.
  • Promoted: 2026-06-12 from learned/ draft to top-level skills/ per operator override of the 30-day soak rule. Rationale recorded in metadata.
Install via CLI
npx skills add https://github.com/CarlosCaPe/octorato --skill teams-1on1-send-no-chat-read
Repository Details
star Stars 5
call_split Forks 5
navigation Branch main
article Path SKILL.md
More from Creator