Skip to content
Back to skills

Teams 1on1 Send No Chat Read

ASecurity

Sends a Teams 1:1 message through the Graph API even when the token does NOT carry Chat.Read scopes: derives the chat ID from the sorted OIDs in the canonical form 19:{oid1}_{oid2}@unq.gbl.spaces.

  • 17 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 2, 2026
ai-agentsjavascriptgojavagitapi

Works with

  • cli
  • api

Security analysis

A100/100

Scanned September 20, 2026

npx -y skills add CarlosCaPe/octorato --skill teams-1on1-send-no-chat-read --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Teams 1on1 Send No Chat Read?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Teams 1on1 Send No Chat Read
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/carloscape-teams-1on1-send-no-chat-read/badge)](https://www.skillsdirectory.com/skills/carloscape-teams-1on1-send-no-chat-read)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: teams-1on1-send-no-chat-read
description: "Sends a Teams 1:1 message through the Graph API even when the token does NOT carry Chat.Read scopes: derives the chat ID from the sorted OIDs in the canonical form 19:{oid1}_{oid2}@unq.gbl.spaces."
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

```javascript
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)

```javascript
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.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.