@clawhub-zhixiangluo-7d03fd8955
Connect OpenClaw to any external tool or service — Slack, GitHub, Jira, Confluence, Grafana, Datadog, PagerDuty, Outlook, Google Drive, and more. Also teache...
---
name: tool-connector
description: Connect OpenClaw to tools like Slack, GitHub, Jira, Confluence, Grafana, Datadog, PagerDuty, Outlook, and Google Drive with minimal input — just paste a URL and the skill figures out the rest. Everything stays local; no credentials or data leave your machine. Uses your own identity (no OAuth apps, no IT tickets). Also teaches how to add brand-new tool connections from scratch (10xProductivity methodology). Use when the user wants to connect to a tool, set up credentials, access a service API, add a new integration, or ask "how do I give my agent access to X". CAUTION: SSO tools use Python Playwright browser automation to capture session tokens from a headed Chromium window; credentials are stored in ~/.openclaw/openclaw.json under skills.entries.tool-connector only. Review scripts/playwright_sso.py and scripts/openclaw_sync.py before use.
metadata:
{
"openclaw":
{
"emoji": "🔌",
"requires": { "bins": ["python3", "pip"] },
"install":
[
{
"id": "python-playwright",
"kind": "pip",
"package": "playwright",
"post_install": "playwright install chromium",
"label": "Install Python Playwright + Chromium (required for SSO tools: Slack, Outlook, Teams, Google Drive, Grafana)"
}
],
"env":
{
"provided":
[
{ "key": "GITHUB_TOKEN", "tool": "GitHub", "kind": "api-token", "lifetime": "long-lived" },
{ "key": "JIRA_API_TOKEN", "tool": "Jira", "kind": "api-token", "lifetime": "long-lived" },
{ "key": "JIRA_EMAIL", "tool": "Jira", "kind": "config", "lifetime": "permanent" },
{ "key": "CONFLUENCE_TOKEN", "tool": "Confluence", "kind": "api-token", "lifetime": "long-lived" },
{ "key": "CONFLUENCE_EMAIL", "tool": "Confluence", "kind": "config", "lifetime": "permanent" },
{ "key": "DATADOG_API_KEY", "tool": "Datadog", "kind": "api-token", "lifetime": "long-lived" },
{ "key": "PAGERDUTY_TOKEN", "tool": "PagerDuty", "kind": "api-token", "lifetime": "long-lived" },
{ "key": "JENKINS_API_TOKEN", "tool": "Jenkins", "kind": "api-token", "lifetime": "long-lived" },
{ "key": "ARTIFACTORY_TOKEN", "tool": "Artifactory", "kind": "api-token", "lifetime": "long-lived" },
{ "key": "BACKSTAGE_TOKEN", "tool": "Backstage", "kind": "api-token", "lifetime": "long-lived" },
{ "key": "BITBUCKET_TOKEN", "tool": "Bitbucket Server", "kind": "api-token", "lifetime": "long-lived" },
{ "key": "GRAFANA_SESSION", "tool": "Grafana", "kind": "sso-cookie", "lifetime": "~8h" },
{ "key": "SLACK_XOXC", "tool": "Slack", "kind": "sso-token", "lifetime": "~8h" },
{ "key": "SLACK_D_COOKIE", "tool": "Slack", "kind": "sso-cookie", "lifetime": "~8h" },
{ "key": "GDRIVE_COOKIES", "tool": "Google Drive", "kind": "sso-cookies", "lifetime": "days-weeks" },
{ "key": "GDRIVE_SAPISID", "tool": "Google Drive", "kind": "sso-token", "lifetime": "days-weeks" },
{ "key": "TEAMS_SKYPETOKEN", "tool": "Microsoft Teams", "kind": "sso-token", "lifetime": "~24h" },
{ "key": "TEAMS_SESSION_ID", "tool": "Microsoft Teams", "kind": "sso-session", "lifetime": "~24h" },
{ "key": "GRAPH_ACCESS_TOKEN", "tool": "Outlook / M365", "kind": "bearer-jwt", "lifetime": "~1h" },
{ "key": "OWA_ACCESS_TOKEN", "tool": "Outlook / M365", "kind": "bearer-jwt", "lifetime": "~1h" }
]
}
}
}
---
# Tool Connector
> **Everything stays local — no data leaves your machine.** Credentials are written only to `~/.openclaw/openclaw.json` on your own filesystem. Nothing is uploaded, proxied, or shared with any cloud service, including OpenClaw's servers. The agent connects directly from your machine to the target tool using your own identity.
>
> **Minimal input by design.** Just paste a URL from the tool — the skill infers the base URL, auth method, and API shape from it. No IT tickets, no OAuth app registration, no config files to hand-edit.
>
> **SSO tools** (Slack, Outlook, Teams, Google Drive, Grafana) use **Python Playwright** (`pip install playwright && playwright install chromium`) to open a headed Chromium window you can see, completing SSO the same way you would manually. The script captures session cookies/tokens from `localStorage` and network headers. **Review `{baseDir}/scripts/shared_utils/playwright_sso.py` before running any SSO flow.**
>
> **Credential storage scope:** All credentials are written into `~/.openclaw/openclaw.json` under `skills.entries.tool-connector.env` **only** — the sync script does not read or modify any other key in that file. SSO tokens are also cached in `~/.openclaw/tool-connector.env` (plain-text, never committed to git). OpenClaw injects them as env vars at the start of each agent session; only store tokens for tools you actively use.
>
> **Full list of credentials this skill may store** (see `metadata.env.provided` above for tool, kind, and lifetime):
> API tokens (long-lived): `GITHUB_TOKEN`, `JIRA_API_TOKEN`, `CONFLUENCE_TOKEN`, `DATADOG_API_KEY`, `PAGERDUTY_TOKEN`, `JENKINS_API_TOKEN`, `ARTIFACTORY_TOKEN`, `BACKSTAGE_TOKEN`, `BITBUCKET_TOKEN`
> SSO tokens (short-lived, refreshed by Playwright): `GRAFANA_SESSION` (~8h), `SLACK_XOXC`/`SLACK_D_COOKIE` (~8h), `GDRIVE_COOKIES`/`GDRIVE_SAPISID` (days–weeks), `TEAMS_SKYPETOKEN`/`TEAMS_SESSION_ID` (~24h), `GRAPH_ACCESS_TOKEN`/`OWA_ACCESS_TOKEN` (~1h)
Gives your OpenClaw agent the ability to connect to tools and services using the [10xProductivity](https://github.com/ZhixiangLuo/10xProductivity) methodology: your agent authenticates *as you*, using the same surfaces you use as a human — no OAuth apps, no cloud middleware, no IT tickets.
## Bundled tool recipes
Verified connection recipes are in `{baseDir}/references/tool_connections/`:
| Tool | Auth method | Reference |
|------|-------------|-----------|
| Artifactory | API token | `tool_connections/artifactory/` |
| Backstage | API token | `tool_connections/backstage/` |
| Bitbucket Server | API token | `tool_connections/bitbucket-server/` |
| Confluence | API token | `tool_connections/confluence/` |
| Datadog | API token | `tool_connections/datadog/` |
| GitHub | API token | `tool_connections/github/` |
| Google Drive | SSO (Playwright) | `tool_connections/google-drive/` |
| Grafana | API token / SSO | `tool_connections/grafana/` |
| Jenkins | API token | `tool_connections/jenkins/` |
| Jira | API token | `tool_connections/jira/` |
| Microsoft Teams | SSO (Playwright) | `tool_connections/microsoft-teams/` |
| Outlook | SSO (Playwright) | `tool_connections/outlook/` |
| PagerDuty | API token | `tool_connections/pagerduty/` |
| Slack | SSO (Playwright) | `tool_connections/slack/` |
For more tools, clone https://github.com/ZhixiangLuo/10xProductivity and run through `setup.md`.
## Which reference to read
**Setting up a connection to a tool already in the list above:**
Read `{baseDir}/references/setup.md` for UX principles, then read the matching `{baseDir}/references/tool_connections/<tool>/setup.md` and `connection-*.md`.
**Adding a brand-new tool not in the list:**
Read `{baseDir}/references/add-new-tool.md` — it walks through the full methodology: research auth, identify base URL, capture credentials, validate against a live instance, and write a reusable recipe.
**SSO-based tools (Slack, Outlook, Google Drive, Teams, Grafana):**
These use **Python Playwright** to open a headed Chromium window, capture a session token, and write it to `~/.openclaw/tool-connector.env`. The script is at `{baseDir}/scripts/shared_utils/playwright_sso.py`. Install once with `pip install playwright && playwright install chromium`; run when a token expires. Session lifetimes vary by tool (see the caution block above).
## Credential storage (OpenClaw standard)
All credentials — both API tokens and SSO session tokens — are stored in `~/.openclaw/openclaw.json` under `skills.entries.tool-connector.env`. OpenClaw injects them automatically as environment variables at the start of each agent run. No manual `source .env` needed.
**API tokens** (long-lived) — add directly to `~/.openclaw/openclaw.json`:
```json5
// ~/.openclaw/openclaw.json
{
skills: {
entries: {
"tool-connector": {
env: {
GITHUB_TOKEN: "ghp_...",
JIRA_API_TOKEN: "...",
JIRA_EMAIL: "[email protected]",
}
}
}
}
}
```
**SSO session tokens** (short-lived: Slack ~8h, M365 ~1h, Teams ~24h) — captured by Playwright and synced automatically into `~/.openclaw/openclaw.json` via the sync script:
```bash
# Refresh Slack SSO and sync into openclaw.json
python3 {baseDir}/scripts/openclaw_sync.py --refresh-slack
# Refresh Outlook/M365 SSO and sync
python3 {baseDir}/scripts/openclaw_sync.py --refresh-outlook
# Refresh all SSO sessions and sync
python3 {baseDir}/scripts/openclaw_sync.py --refresh-all
# Sync already-captured tokens (no browser) — useful after manual SSO run
python3 {baseDir}/scripts/openclaw_sync.py
```
SSO tokens are cached in `~/.openclaw/tool-connector.env` (never in git). The sync script reads that file and patches `~/.openclaw/openclaw.json` so OpenClaw picks them up on the next session.
**Never store credentials in the skill directory itself.**
## Core principles (from 10xProductivity)
- **Ask for a URL first** — any link from the tool reveals the base URL, variant, and proves access
- **Infer auth from the URL** — do not ask the user to explain their auth setup
- **Run before you write** — every snippet must be code you actually executed against a live instance
- **Zero friction** — no OAuth app creation, no IT tickets, no new cloud services
- **Agent acts as you** — your identity, your audit trail, your credentials
FILE:references/add-new-tool.md
---
name: add-new-tool
description: Add a new tool from scratch — research auth, validate against a live instance, write files to personal/{tool-name}/. Use when a tool has no recipe yet. Do NOT use this if the tool already exists in tool_connections/ — use setup.md instead.
---
# Add a New Tool
> **What this file is for:** The tool has no recipe yet. You are building one from scratch — researching auth, validating against a live instance, and writing the files to `personal/{tool-name}/` for your own use.
>
> **Wrong file?** If the tool already exists in `tool_connections/`, use `setup.md` instead — that one connects your own instance using an existing recipe.
>
> **Want to contribute back?** After completing Phase 1, read `contributing.md`.
## Purpose
Turn "I want my agent to access Tool X" into a working, verified connection file that any agent can pick up and use.
**Phase 1 (always):** Research, validate, and write the connection for your own use.
**Phase 2 (optional):** Contribute it back to the repo as a PR — only if the tool is commercial and publicly available.
---
## Non-negotiable rules
1. **Research viability first.** Before asking the user for anything, determine what auth methods exist for this tool. If no viable method exists (no public API, no session-based workaround, no OAuth path), stop — there is nothing to build.
2. **Ask only what the auth method actually needs.** The credential ask must be proportional to the auth method: SSO/browser-session → ask for nothing (just a URL to confirm the instance); API token → ask for the token and where to generate it; username+password → ask for both. Never ask vague questions the user can't answer.
3. **A URL is your best minimal input.** If you need to confirm an instance, ask for any URL from that tool (profile page, dashboard, ticket). It reveals the base URL, regional variant, and proves the user has access — without requiring them to know anything about auth.
4. **Run before you write.** Every snippet must be code you actually executed and saw succeed against a live instance. No copy-paste from docs. No illustrative output. The reason you haven't run them does not matter — unverified snippets do not belong in a connection file.
5. **Write for the next agent.** Strip session-specific IDs, one-time URLs, org-specific data. Document the pattern, not the artifact.
6. **Nothing broken.** If an endpoint didn't work, cut it. One working snippet beats five broken ones.
---
## Phase 1: Create and Verify
### Step 0: Research viability — stop here if no path exists
Before asking the user for anything:
1. Research what auth methods exist for this tool (official API docs, OAuth, browser session, etc.)
2. Pick the best viable method using the priority order below
3. Determine exactly what that method needs from the user
**This repo's goal is zero-friction setup.** The user should never have to create an app, register OAuth credentials, or configure anything outside of this repo's own flow. Reject any auth approach that requires that — even if it's technically cleaner.
**Auth method priority order:**
| Priority | Auth method | User friction | Ask the user for |
|----------|-------------|---------------|-----------------|
| 1 | **API token** | Near-zero — generate in tool settings (~30s) | The token + where to generate it |
| 2 | **Browser session (SSO, one-time capture)** | Near-zero — run `sso.py` once, cached days/weeks | A URL from the tool — nothing else |
| 3 | **Browser session (per operation)** | Low but costly — Playwright runs on every call | A URL from the tool — nothing else |
| 4 | **Username + password** | Low — but only for legacy tools | Username and password |
| ✗ | **OAuth requiring user to create their own app** | High — stop, do not use | N/A |
**On browser automation cost:** distinguish setup cost from per-operation cost.
- *SSO capture (Priority 2)* — Playwright runs once. Session is saved to disk and reused. This is acceptable and often the only option for SSO tools (Slack, Teams, Google Workspace).
- *Per-operation browser (Priority 3)* — Playwright launches on every `search()`, `read()`, or `list()` call. Only accept this if there is genuinely no API or export endpoint. Document it explicitly in the connection file.
**On OAuth:** OAuth is acceptable *only* when the repo ships pre-configured client credentials (the user just clicks "Authorize" in their browser — zero app creation). OAuth that requires the user to create a Google Cloud project, register a redirect URI, or configure a consent screen is **not acceptable** — the friction cost makes it worse than a browser session.
**Stop and explain** if the only viable path requires the user to create an app or register OAuth credentials. Don't propose it as an option — it violates this repo's zero-friction goal.
If a viable zero-friction method exists → ask the user only for what that method requires, then proceed to Step 1.
---
### Step 1: Identify the base URL
If the user provided a URL (login page, dashboard, ticket), probe it first:
```bash
curl -sI --max-time 10 "https://{the-url}" | head -5
```
Sites redirect. Confirm the real base URL before researching. Note any site-variant clues (e.g. `us5.datadoghq.com` → API base is `api.us5.datadoghq.com`).
---
### Step 2: Research the API
Do not guess. Find the official API docs.
**Search order:**
1. Official docs (`docs.tool.com/api` or `developer.tool.com`)
2. OpenAPI/Swagger spec (`/api/swagger.json`, `/openapi.json`)
3. GitHub code search — working callers are more accurate than docs
**Collect before moving on:**
- Base URL (production)
- Auth mechanism (API key, Bearer token, session cookie, OAuth2) and header name
- Token lifetime and refresh method
- Key endpoints: health/version (no auth), list, get
- Search/query interface if any
- Network requirements (VPN?)
- Env var names to use
---
### Step 3: Store credentials
Add to `.env` (repo root) only — do not edit root `env.sample` (it is a stub) or other shared index files. Document new variables in `personal/{tool}/setup.md` under **`.env` entries**.
> **Watch for tools with explicit resource-sharing requirements.** Some tools (e.g. Notion) require you to explicitly grant the integration access to specific resources (pages, databases) even after auth succeeds. Workspace-level installation ≠ data access. If auth passes but read endpoints return 404 or empty results, look for a resource-level sharing step — usually found in the tool's Settings → Integrations/Apps → edit the integration → content/resource access panel. Document this in the Notes section of the connection file.
```bash
# --- Tool Name ---
TOOL_API_TOKEN=your-api-token-here
TOOL_BASE_URL=https://api.tool.com
# Generate at: https://tool.com/settings/api-tokens
# Token lifetime: long-lived / ~8h (refresh with: ...)
```
---
### Step 4: Validate against the live instance
**Do not use dev environments.** Validate on the actual production endpoint.
#### 4a. Connectivity (no auth)
```bash
curl -sI --max-time 10 "$TOOL_BASE_URL/health" # or /version, /ping, /api/v1/status
```
- 200 → proceed
- SSL error → VPN may be required; document it
- Timeout → wrong URL
#### 4b. Auth
```bash
source .env
# Try the auth pattern from docs
curl -s "$TOOL_BASE_URL/some-read-endpoint" \
-H "Authorization: Bearer $TOOL_API_TOKEN" | jq .
# If header name is unclear, probe common patterns:
for h in "Authorization: Bearer $TOOL_API_TOKEN" "X-API-Key: $TOOL_API_TOKEN" "api-key: $TOOL_API_TOKEN"; do
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$TOOL_BASE_URL/some-endpoint" -H "$h")
echo "$h → HTTP $code"
done
```
#### 4c. Key read endpoints
Run at least 2 read endpoints and capture real output:
```bash
curl -s "$TOOL_BASE_URL/users/me" -H "Authorization: Bearer $TOOL_API_TOKEN" | jq .
# → {"id": "u_123", "name": "Alice", "email": "[email protected]"}
curl -s "$TOOL_BASE_URL/items?limit=5" -H "Authorization: Bearer $TOOL_API_TOKEN" | jq .
# → [{"id": "p_1", "name": "My Item"}, ...]
```
Record both successes and permission errors — both are useful.
#### 4d. Native search and AI/chat
**Always check — this is what makes a connection genuinely useful to an agent.**
For every tool, answer these two questions before writing the connection file:
1. **Does it have a search API?** (full-text, title-based, filter-based — any kind)
- Try common patterns: `/search`, `/api/search`, `?q=`, `?query=`
- Run it. Record what fields it searches, what it returns, and any limitations (e.g. title-only, indexed with delay).
2. **Does it have an AI or chat API?** (LLM-backed Q&A, summarization, assistant endpoint)
- Check official docs for "AI", "assistant", "chat", "copilot" endpoints.
- If none exist in the public API, say so explicitly — do not leave it ambiguous.
**Document the result in the Notes section of the connection file:**
- If search works: show a verified snippet with real output.
- If AI/chat exists: show the endpoint and a verified call.
- If neither exists or is paywalled: state it clearly (e.g. "No search API." or "AI chat is enterprise-only, no public endpoint.").
Skipping this step leaves the agent blind to the tool's most useful capabilities.
---
### Step 5: Write the connection file
**Location:** `personal/{tool-name}/` — always. This is gitignored and never committed.
Do not write to `tool_connections/`, `staging/`, or anywhere else outside `personal/`.
**Format** (use `staging/_example/` as reference):
```markdown
---
tool: {tool-name}
auth: {api-token|oauth|sso|ad-sso|session-cookie}
author: {github-username}
verified: {YYYY-MM}
env_vars:
- TOOL_API_TOKEN
- TOOL_BASE_URL
---
# {Tool Name} — {auth method}
{1-2 sentences: what it is, who uses it.}
API docs: {URL}
**Verified:** Production ({base-url}) — {endpoints tested} — {YYYY-MM}. {VPN required / not required.}
---
## Credentials
\`\`\`bash
# Add to .env:
# TOOL_API_TOKEN=your-token-here
# TOOL_BASE_URL=https://api.tool.com
# Generate at: {URL}
\`\`\`
---
## Auth
{Auth flow in 1-2 sentences.}
\`\`\`bash
source .env
curl -s "$TOOL_BASE_URL/endpoint" \
-H "Authorization: Bearer $TOOL_API_TOKEN" | jq .
# → {actual output}
\`\`\`
---
## Verified snippets
\`\`\`bash
source .env
BASE="$TOOL_BASE_URL"
# {What this does}
curl -s "$BASE/endpoint" -H "Authorization: Bearer $TOOL_API_TOKEN" | jq .
# → {actual output}
\`\`\`
---
## Notes
- {Permission requirements}
- {VPN requirement}
- {Known limitations}
```
**Snippet rules:**
- Only include commands you actually ran and saw succeed
- Every snippet has a `# → {actual output}` comment (truncate long output with `# → [{...}, ...]`)
- Permission errors are valid: `# → 403 Forbidden — requires Admin role`
- Cut anything that didn't work
---
### Step 6: Update verified_connections.md
Once the connection file is written and at least 2 snippets are verified with real output, add the tool to your active capability index.
Open `setup.md` Step 3, add the tool name to `VERIFIED_NAMES`, and run the script:
```python
VERIFIED_NAMES = [
# ... existing tools ...
"{tool-name}", # ← add this
]
```
```bash
# Run the script from setup.md Step 3 to regenerate verified_connections.md
source .venv/bin/activate
python3 - << 'EOF'
# ... paste the script from setup.md Step 3 here with your tool added ...
EOF
```
Then reload `verified_connections.md` — the new tool is now live in your session.
---
## Phase 2: Contribute back (optional)
If the tool is commercial/publicly available and you want to share the connection with the community, read `contributing.md` — it covers the full process: eligibility check, scrubbing personal data, and opening the PR.
---
## Checklist — do not mark done until all boxes checked
- [ ] Auth method researched and confirmed viable before asking user anything
- [ ] Asked user only for what the auth method actually requires
- [ ] Base URL confirmed (not guessed)
- [ ] Auth mechanism identified and tested on production
- [ ] At least 2 read endpoints run, real output recorded
- [ ] Native search API tested — verified snippet recorded, or explicitly noted as absent
- [ ] AI/chat API checked — verified snippet recorded, or explicitly noted as unavailable/paywalled
- [ ] `verified: YYYY-MM` filled in (blank = not ready)
- [ ] `.env` updated with new credentials
- [ ] All files written to `personal/{tool-name}/` only — nothing outside `personal/`
- [ ] File written with only verified snippets
- [ ] `verified_connections.md` regenerated with new tool added to `VERIFIED_NAMES`
**To contribute back:** see `contributing.md`
FILE:references/setup.md
# Setup Guide
> **What this file is for:** The tool already has a recipe in `tool_connections/`. You are connecting your own instance of it — putting credentials in `.env` and verifying they work.
>
> **Wrong file?** If the tool doesn't exist in `tool_connections/` yet, use `add-new-tool.md` instead — that one builds the recipe from scratch.
This file is for your agent. Point your agent here first:
> *"Read setup.md and set up my tool connections."*
---
## Agent UX principles — read this first
**Do as much as possible. Ask as little as possible. Ask non-technically.**
- Run every command yourself. Never paste a command and ask the user to run it.
- **Ask for a URL first.** For any tool, the best minimal input is a URL the user already has open (a ticket, a message link, a dashboard URL). It reveals the base URL, workspace, and regional variant — without requiring the user to know anything about auth.
- **Infer the auth method from the URL, then try it.** Check the tool's `setup.md` to determine the auth method. For SSO/browser-session tools, attempt Playwright immediately — no further questions needed. For API token tools, check `.env` first — the token may already be there.
- **Ask for credentials only if actually missing, and only for the specific thing that's missing.** Never ask vague questions like "do you have credentials?" Know what you need before you ask.
- When you must ask, phrase it in plain language — not in technical terms.
- As soon as you have what you need, do the work and verify it yourself. Tell the user what succeeded, not what they need to do next.
- **If a recipe fails, do not modify `tool_connections/` directly.** Copy the relevant files to `personal/{tool-name}/`, patch and verify there, then follow `contributing.md` to propose the fix upstream. Never silently change a shared recipe as a side effect of setup.
**Minimum user input by tool:**
| Tool | What to ask for | Auth method |
|------|----------------|-------------|
| **Slack** | Any Slack message link | SSO → run `tool_connections/slack/sso.py` |
| **Jira** | Any Jira ticket URL + API token + email | API token (Basic auth) |
| **GitHub** | PAT (+ repo URL if GHE) | API token (Bearer) |
| **Confluence** | Any Confluence page URL + API token + email | API token (Basic auth) |
| **Grafana** | Grafana URL | SSO → run `tool_connections/grafana/sso.py` |
| **PagerDuty** | API key | API token |
| **Microsoft Teams** | Any Teams link | SSO → run `tool_connections/microsoft-teams/sso.py` |
| **Outlook / M365** | Any Outlook URL | SSO → run `tool_connections/outlook/sso.py` |
| **Outlook.com** | Any Outlook URL | Token capture → run `tool_connections/outlook/get_outlook_token.py` |
| **Google Drive** | Nothing | Browser session → run `tool_connections/google-drive/sso.py` |
| **Notion** | API token from notion.so/my-integrations | API token (Bearer) — then grant page access via Settings → Integrations → Edit → Content access |
| **Datadog** | Datadog URL + API key + App key | API key |
---
## Prerequisites
```bash
# Clone and create Python env
git clone https://github.com/yourusername/10xProductivity.git
cd 10xProductivity
python3 -m venv .venv && source .venv/bin/activate
pip install playwright && playwright install chromium
# Create .env (empty — fill from each tool's setup.md as you connect)
touch .env
```
---
## Step 1: Ask the user which tools they use
Ask once, simply:
> *"Which of these tools does your team use?"*
> - Confluence (internal wiki / docs)
> - Slack
> - Jira
> - GitHub (or GitHub Enterprise)
> - Microsoft Teams ("Share any Teams link — I'll detect personal vs enterprise")
> - Outlook ("Share any Outlook link — I'll detect Outlook.com vs Microsoft 365")
> - Grafana
> - PagerDuty / OpsGenie
> - Google Drive / Google Workspace
> - Datadog / Splunk
> - Artifactory
> - Bitbucket Server
> - Jenkins
> - Backstage
> - Other (describe — check `personal/` for existing recipes, or run `add-new-tool.md` to build one)
Only set up what they actually use. Don't touch tools they don't have.
**Tool not in the list above?** Check `personal/` first — if a recipe exists there, use it. If not, run `add-new-tool.md` to build one from scratch (it will write to `personal/`).
---
## Step 2: Set up tools in priority order
**Validation is mandatory.** For every tool, run the verify snippet and confirm it returns expected output before moving on.
Start with **Tier 1** — these make everything else easier.
### Tier 1 — Knowledge & Context
| Tool | Setup file |
|------|-----------|
| Confluence | `tool_connections/confluence/setup.md` |
| Slack | `tool_connections/slack/setup.md` |
| Jira | `tool_connections/jira/setup.md` |
| GitHub | `tool_connections/github/setup.md` |
| Microsoft Teams | `tool_connections/microsoft-teams/setup.md` |
| Outlook | `tool_connections/outlook/setup.md` |
### Tier 2 — Observability & Operations
| Tool | Setup file |
|------|-----------|
| Grafana | `tool_connections/grafana/setup.md` |
| PagerDuty | `tool_connections/pagerduty/setup.md` |
| Datadog | `tool_connections/datadog/setup.md` |
### Tier 3 — File & Document Access
| Tool | Setup file |
|------|-----------|
| Google Drive | `tool_connections/google-drive/setup.md` |
### Tier 4 — Dev Infrastructure
| Tool | Setup file |
|------|-----------|
| Artifactory | `tool_connections/artifactory/setup.md` |
| Bitbucket Server | `tool_connections/bitbucket-server/setup.md` |
| Jenkins | `tool_connections/jenkins/setup.md` |
| Backstage | `tool_connections/backstage/setup.md` |
For each tool: read its `setup.md`, follow the steps, run the verify snippet, confirm it passes.
---
## Step 3: Generate verified_connections.md
**Only tools whose Verify command you actually ran and confirmed with real output belong here.**
For each tool set up in Step 2, you ran a Verify snippet and saw expected output. Collect only those tool names into `VERIFIED_NAMES` below, then run the script to generate `verified_connections.md`.
Tools can come from `tool_connections/` (core) or `personal/` (your own) — include them all here regardless of origin.
```python
import re, os
from pathlib import Path
# EDIT THIS LIST: only tools whose Verify command you ran and confirmed
# Include tools from tool_connections/ AND personal/ — origin doesn't matter
VERIFIED_NAMES = [
# Core tools (tool_connections/):
# "confluence",
# "slack",
# "jira",
# "github",
# "grafana",
# "pagerduty",
# "google-drive",
# "microsoft-teams",
# "outlook",
# "datadog",
# "artifactory",
# "bitbucket-server",
# "jenkins",
# "backstage",
# Personal tools (personal/):
# "my-internal-tool",
]
# Determine which tools are verified
verified_names = VERIFIED_NAMES
# Build verified_connections.md by filtering the example to verified tools only
example = Path("verified_connections.example.md").read_text()
chunks = re.split(r"\n---\n", example)
def tool_slug(name):
return name.lower().replace(" ", "-").replace("/", "-")
def is_verified_section(chunk):
m = re.match(r"^##\s+(\S+)", chunk.strip())
if not m:
return False
slug = tool_slug(m.group(1))
return any(v in slug or slug in v for v in verified_names)
def filter_table_rows(text):
lines = text.splitlines()
out = []
in_table = False
for line in lines:
if "| Tool" in line or line.startswith("|---"):
in_table = True
out.append(line)
elif in_table and line.startswith("|"):
tool_m = re.search(r"\*\*(.+?)\*\*", line)
if tool_m:
slug = tool_slug(tool_m.group(1))
if any(v in slug or slug in v for v in verified_names):
out.append(line)
else:
in_table = False
out.append(line)
return "\n".join(out)
header_chunks, section_chunks = [], []
for chunk in chunks:
(section_chunks if re.match(r"^##\s+\w", chunk.strip()) else header_chunks).append(chunk)
filtered_header = "\n---\n".join(
filter_table_rows(c) if "| Tool" in c else c for c in header_chunks
)
verified_sections = [c for c in section_chunks if is_verified_section(c)]
output = filtered_header
if verified_sections:
output += "\n---\n" + "\n---\n".join(verified_sections)
tool_list = ", ".join(verified_names) if verified_names else "none"
output = re.sub(
r"(description: ).*?(\n)",
lambda m_: m_.group(1) + f"Your active tool connections — verified and ready. Covers: {tool_list}. Load at session start." + m_.group(2),
output, count=1
)
new_preamble = (
"**Keep this file loaded for the entire session.** These tools are verified and ready — "
"use them proactively in any task across any codebase.\n\n"
"Individual tool files have full connection details — load them on demand.\n\n"
"**Refresh short-lived tokens (~8h):** run the tool's `sso.py` "
"(e.g. `source .venv/bin/activate && python3 tool_connections/slack/sso.py`)"
)
output = re.sub(
r"\*\*This is the example file\.\*\*.*?(?=\n---\n|\n## )",
new_preamble,
output,
flags=re.DOTALL
)
Path("verified_connections.md").write_text(output)
print(f"verified_connections.md written. Active tools: {verified_names}")
```
Then summarize for the user what connected and what was skipped.
**Now load `verified_connections.md` immediately.** It is your capability index for this session.
---
## Refreshing short-lived tokens
| Tool | Command | TTL |
|------|---------|-----|
| Slack | `python3 tool_connections/slack/sso.py` | ~8h |
| Grafana | `python3 tool_connections/grafana/sso.py` | ~8h |
| Outlook / M365 | `python3 tool_connections/outlook/sso.py` | ~1h |
| Outlook.com | `python3 tool_connections/outlook/get_outlook_token.py` | ~1h |
| Teams (personal) | `python3 tool_connections/microsoft-teams/sso.py` | ~24h |
| Google Drive | `python3 tool_connections/google-drive/sso.py` | days–weeks |
Always `source .venv/bin/activate` first.
---
## If something broke during setup
**Do not edit `tool_connections/` directly.** That folder is shared — changes made here affect everyone.
Instead:
1. Copy `tool_connections/{tool}/` → `personal/{tool}/`
2. Patch and verify it there
3. Use the patched `personal/{tool}/` recipe for your session
4. Follow `contributing.md` ("Fixes and improvements") to propose the fix upstream
| What broke | Where to look for the cause |
|------------|-----------------------------|
| Wrong setup instructions | `tool_connections/{tool}/setup.md` (read to understand, patch in `personal/`) |
| Wrong API snippet | `tool_connections/{tool}/connection-*.md` (read to understand, patch in `personal/`) |
| SSO script failure | `tool_connections/{tool}/sso.py` (read to understand, patch in `personal/`) |
See `add-new-tool.md` for creating connections for tools that don't exist in the repo yet.
FILE:references/tool_connections/artifactory/connection-api-token.md
---
name: artifactory
auth: api-token
description: JFrog Artifactory — universal artifact repository manager for PyPI, Maven, npm, Docker, and more. Use when finding artifact versions, browsing repos, searching for packages, or downloading build artifacts.
env_vars:
- ARTIFACTORY_USER
- ARTIFACTORY_TOKEN
- ARTIFACTORY_BASE_URL
---
# Artifactory — API token (Basic auth)
JFrog Artifactory is a universal artifact repository manager used to store, manage, and distribute build artifacts — PyPI packages, Maven JARs, npm modules, Docker images, and generic binaries. Common use cases: find the latest version of an internal package, check what artifacts a build produced, download a specific release.
API docs: https://jfrog.com/help/r/jfrog-rest-apis/artifactory-rest-apis
**Verified:** Production (Artifactory Enterprise 7.x) — `/api/system/ping` + `/api/repositories` + `/api/storage/{repo}/{path}` + `/api/search/artifact` — 2026-03. No VPN required (depends on your instance network policy).
---
## Credentials
```bash
# Add to .env:
# ARTIFACTORY_USER=your-username
# ARTIFACTORY_TOKEN=your-api-key-or-token
# ARTIFACTORY_BASE_URL=https://artifactory.yourcompany.com
#
# Generate API key: Artifactory UI → top-right user icon → Edit Profile → Authentication Settings → Generate API Key
# Or (Artifactory 7.21+): Administration → Identity and Access → Access Tokens → Generate Token
```
---
## Auth
Basic auth with base64-encoded `user:token`. Set `AUTH` once per session:
```bash
source .env
AUTH=$(echo -n "$ARTIFACTORY_USER:$ARTIFACTORY_TOKEN" | base64)
BASE="$ARTIFACTORY_BASE_URL"
```
---
## Verify connection
```bash
source .env
AUTH=$(echo -n "$ARTIFACTORY_USER:$ARTIFACTORY_TOKEN" | base64)
curl -s -H "Authorization: Basic $AUTH" \
"$ARTIFACTORY_BASE_URL/artifactory/api/system/ping"
# → OK
# If 401: wrong user or token. If connection refused: check ARTIFACTORY_BASE_URL.
```
---
## Verified snippets
```bash
source .env
AUTH=$(echo -n "$ARTIFACTORY_USER:$ARTIFACTORY_TOKEN" | base64)
BASE="$ARTIFACTORY_BASE_URL"
# List local repositories (first 5)
curl -s -H "Authorization: Basic $AUTH" \
"$BASE/artifactory/api/repositories?type=local" \
| jq '.[:5] | .[] | {key, packageType}'
# → [{"key": "libs-release-local", "packageType": "Generic"}, {"key": "npm-local", "packageType": "Npm"}, ...]
# Browse a package folder (list all versions)
curl -s -H "Authorization: Basic $AUTH" \
"$BASE/artifactory/api/storage/{repo-key}/{package-name}/" \
| jq '{repo, path, children: [.children[].uri]}'
# → {"repo": "python-dev", "path": "/my-package", "children": ["/1.0.0", "/1.0.1", "/1.1.0"]}
# Get the latest version (sort children and take the last)
curl -s -H "Authorization: Basic $AUTH" \
"$BASE/artifactory/api/storage/{repo-key}/{package-name}/" \
| jq '.children[].uri' | sort -V | tail -1
# → "/1.1.0"
# Get file info for a specific version folder
curl -s -H "Authorization: Basic $AUTH" \
"$BASE/artifactory/api/storage/{repo-key}/{package-name}/1.1.0/" \
| jq '{repo, path, children: [.children[].uri]}'
# → {"repo": "python-dev", "path": "/my-package/1.1.0", "children": ["/my_package-1.1.0-py3-none-any.whl", "/my_package-1.1.0.tar.gz"]}
# Search for artifacts by name
curl -s -H "Authorization: Basic $AUTH" \
"$BASE/artifactory/api/search/artifact?name={artifact-name}&repos={repo-key}" \
| jq '{results_count: (.results | length), sample: [.results[:3][].uri]}'
# → {"results_count": 2, "sample": ["https://.../{artifact-name}-1.1.0.whl", ...]}
# Download an artifact
curl -s -H "Authorization: Basic $AUTH" \
"$BASE/artifactory/{repo-key}/{package-name}/1.1.0/my_package-1.1.0.tar.gz" \
-o my_package.tar.gz
# → downloads to my_package.tar.gz
```
---
## Notes
- **API key vs access token:** Older instances expose a per-user API key under Edit Profile. Artifactory 7.21+ also has scoped Access Tokens (Administration → Identity and Access → Access Tokens). Either works with Basic auth as `user:key`. API keys generated under Edit Profile are non-expiring. Access tokens can have a TTL — check the expiry field when generating.
- **`sort -V`:** Version sort (`sort -V`) handles semver correctly — `1.9.0` before `1.10.0`. Plain `sort` will mis-order.
- **packageType values:** `Generic`, `Maven`, `Gradle`, `Npm`, `PyPI`, `Docker`, `Helm`, `YUM`, `Debian`, etc.
- **SSL:** Enterprise instances often use internal CAs — add `-k` to curl if you see SSL errors.
- **Network:** Most enterprise Artifactory instances are internal-only — VPN may be required.
- **Anonymous read:** Some repos allow anonymous access. If you only need to download public artifacts, skip auth entirely.
FILE:references/tool_connections/artifactory/setup.md
---
name: artifactory-setup
description: Set up Artifactory connection. API key or access token + Basic auth. Ask for Artifactory URL and credentials.
---
# Artifactory — Setup
## Step 1: Ask for a URL
Ask the user: "Share your Artifactory URL" (e.g. `https://artifactory.yourcompany.com`).
Infer `ARTIFACTORY_BASE_URL` from the URL.
---
## Auth method: API key or Access Token (Basic auth)
**What to ask the user:**
- "Your Artifactory username"
- "Your Artifactory API key or access token":
- API key: Artifactory UI → top-right user icon → Edit Profile → Authentication Settings → Generate API Key
- Access token (7.21+): Administration → Identity and Access → Access Tokens → Generate Token
---
## Set `.env`
```bash
ARTIFACTORY_USER=your-username
ARTIFACTORY_TOKEN=your-api-key-or-token
ARTIFACTORY_BASE_URL=https://artifactory.yourcompany.com
```
---
## Verify
```bash
source .env
AUTH=$(echo -n "$ARTIFACTORY_USER:$ARTIFACTORY_TOKEN" | base64)
curl -s -H "Authorization: Basic $AUTH" \
"$ARTIFACTORY_BASE_URL/artifactory/api/system/ping"
# → OK
# If 401: wrong user or token. If connection refused: check ARTIFACTORY_BASE_URL.
```
**Connection details:** `tool_connections/artifactory/connection-api-token.md`
---
## `.env` entries
```bash
# --- Artifactory ---
ARTIFACTORY_USER=your-username
ARTIFACTORY_TOKEN=your-api-key-or-access-token
ARTIFACTORY_BASE_URL=https://artifactory.yourcompany.com
# Generate API key: Artifactory UI → top-right user icon → Edit Profile → Authentication Settings → Generate API Key
# Or (7.21+): Administration → Identity and Access → Access Tokens → Generate Token
```
FILE:references/tool_connections/backstage/connection-api-token.md
---
name: backstage
auth: api-token
description: Backstage — Internal Developer Portal for software catalog management. Use when finding service owners, looking up team members, discovering PagerDuty/GitHub/Slack annotations for any component, or browsing the catalog.
env_vars:
- BACKSTAGE_TOKEN
- BACKSTAGE_BASE_URL
---
# Backstage — API token (Bearer auth)
Backstage is Spotify's open-source Internal Developer Portal, used by many engineering organizations to manage a software catalog of services, teams, users, and resources. Common use cases: find who owns a service, look up a team's members, discover PagerDuty/Slack/GitHub annotations for any component.
API docs: https://backstage.io/docs/features/software-catalog/software-catalog-api
**Verified:** Production (self-hosted Backstage) — `/api/catalog/entity-facets` + `/api/catalog/entities/by-query` + `/api/catalog/entities/by-name` — 2026-03. No VPN required (depends on your deployment).
---
## Credentials
```bash
# Add to .env:
# BACKSTAGE_TOKEN=your-backstage-token
# BACKSTAGE_BASE_URL=https://backstage.yourcompany.com
#
# Token type depends on your Backstage deployment:
# Static token (long-lived): set in app-config.yaml under backend.auth.keys
# Ask your platform team: "Can I get a static Backstage token for local agent use?"
# SSO-issued JWT (short-lived, ~8h): log in via your identity provider and capture the token
# from your browser's local storage: DevTools → Application → Local Storage → backstage
# → look for a key containing "token"
```
---
## Auth
Bearer token in the `Authorization` header:
```bash
source .env
BASE="$BACKSTAGE_BASE_URL"
# Usage: -H "Authorization: Bearer $BACKSTAGE_TOKEN"
```
---
## ⚠ Endpoint gotcha
`/api/catalog/entities?filter=...` is the **deprecated endpoint** — it returns null-filled objects. Always use:
- `/api/catalog/entities/by-query?filter=...` — filtered list (modern)
- `/api/catalog/entities/by-name/{kind}/{namespace}/{name}` — single entity by exact name
---
## Verify connection
```bash
source .env
curl -s -k "$BACKSTAGE_BASE_URL/api/catalog/entity-facets?facet=kind" \
-H "Authorization: Bearer $BACKSTAGE_TOKEN" \
| jq '.facets.kind'
# → [{"value": "Component", "count": 1154}, {"value": "Group", "count": 4373}, {"value": "User", "count": 28244}, ...]
# If 401: token expired or wrong — refresh or ask your platform team.
```
---
## Verified snippets
```bash
source .env
BASE="$BACKSTAGE_BASE_URL"
# Entity kinds and counts — good sanity check
curl -s -k "$BASE/api/catalog/entity-facets?facet=kind" \
-H "Authorization: Bearer $BACKSTAGE_TOKEN" \
| jq '.facets.kind'
# → [{"value": "Component", "count": 1154}, {"value": "Domain", "count": 15}, {"value": "Group", "count": 4373}, {"value": "System", "count": 65}, ...]
# List services (first 3, sparse fieldset — faster for large catalogs)
curl -s -k "$BASE/api/catalog/entities/by-query?filter=kind=component,spec.type=service&limit=3&fields=metadata.name,spec.owner,spec.lifecycle" \
-H "Authorization: Bearer $BACKSTAGE_TOKEN" \
| jq '{totalItems, items: [.items[] | {name: .metadata.name, owner: .spec.owner, lifecycle: .spec.lifecycle}]}'
# → {"totalItems": 850, "items": [{"name": "my-service", "owner": "group:platform-team", "lifecycle": "production"}, ...]}
# Get a single component by exact name
curl -s -k "$BASE/api/catalog/entities/by-name/component/default/{service-name}" \
-H "Authorization: Bearer $BACKSTAGE_TOKEN" \
| jq '{name: .metadata.name, type: .spec.type, owner: .spec.owner, lifecycle: .spec.lifecycle, annotations: .metadata.annotations}'
# → {"name": "my-service", "type": "service", "owner": "group:platform-team", "lifecycle": "production", "annotations": {...}}
# User lookup by username
curl -s -k "$BASE/api/catalog/entities/by-name/user/default/{username}" \
-H "Authorization: Bearer $BACKSTAGE_TOKEN" \
| jq '{name: .metadata.name, email: .spec.profile.email, displayName: .spec.profile.displayName, memberOf: .spec.memberOf}'
# → {"name": "alice", "email": "[email protected]", "displayName": "Alice Smith", "memberOf": ["group:platform-team"]}
# Group lookup by name
curl -s -k "$BASE/api/catalog/entities/by-name/group/default/{group-name}" \
-H "Authorization: Bearer $BACKSTAGE_TOKEN" \
| jq '{name: .metadata.name, members: .spec.members[:5]}'
# → {"name": "platform-team", "members": ["alice", "bob", "carol"]}
# Component type breakdown (facet)
curl -s -k "$BASE/api/catalog/entity-facets?facet=spec.type&filter=kind=component" \
-H "Authorization: Bearer $BACKSTAGE_TOKEN" \
| jq '{"spec.type": .facets["spec.type"]}'
# → {"spec.type": [{"value": "service", "count": 850}, {"value": "resource", "count": 200}, {"value": "library", "count": 104}, ...]}
```
---
## Common annotations to look for
Components carry annotations linking to other tools. Common standard ones:
| Annotation | Example value | Links to |
|---|---|---|
| `pagerduty.com/service-id` | `P1234AB` | PagerDuty service |
| `github.com/project-slug` | `org/repo` | GitHub repo |
| `backstage.io/source-location` | `url:https://...` | Catalog YAML source in git |
| `backstage.io/managed-by-location` | `url:https://...` | Where Backstage manages this entity from |
Check what annotations your instance uses:
```bash
curl -s -k "$BACKSTAGE_BASE_URL/api/catalog/entities/by-query?filter=kind=component&limit=3" \
-H "Authorization: Bearer $BACKSTAGE_TOKEN" \
| jq '.items[].metadata.annotations | keys'
```
---
## Notes
- **namespace:** Most entities use `default`. Multi-tenant setups may use other namespaces — check entity listings.
- **Filter syntax:** Comma (`,`) = AND within one `filter=` param. Multiple `&filter=` params = OR.
- **fields= sparse fieldset:** Always use `fields=metadata.name,spec.owner,...` for list queries — it significantly reduces response size. On large catalogs (10k+ entities or 28k+ users), omitting it can return several MB per page.
- **Pagination:** Use `&offset={N}` to page through results. `totalItems` tells you the total count.
- **Token lifetime:** Static tokens are long-lived. SSO-issued JWTs expire in ~8h — re-capture from browser local storage or ask your platform team for a static token.
- **-k flag:** Deployments using internal CAs — add `-k` to curl or configure the cert.
FILE:references/tool_connections/backstage/setup.md
---
name: backstage-setup
description: Set up Backstage connection. Bearer token auth — either a static long-lived token (from platform team) or a short-lived SSO JWT (from browser local storage). Ask for Backstage URL.
---
# Backstage — Setup
## Step 1: Ask for a URL
Ask the user: "Share your Backstage URL" (e.g. `https://backstage.yourcompany.com`).
Infer `BACKSTAGE_BASE_URL` from the URL.
---
## Auth method: Bearer token
Two token types depending on your Backstage deployment:
**Static token (long-lived — preferred):**
Set in `app-config.yaml` under `backend.auth.keys`. Ask your platform team: "Can I get a static Backstage token for local agent use?"
**SSO-issued JWT (short-lived, ~8h):**
Log in via your identity provider and capture the token from browser local storage:
DevTools → Application → Local Storage → your Backstage URL → look for a key containing `"token"`
**What to ask the user:** "Paste your Backstage token" (either static or SSO JWT from browser local storage).
---
## Set `.env`
```bash
BACKSTAGE_TOKEN=your-backstage-token
BACKSTAGE_BASE_URL=https://backstage.yourcompany.com
```
---
## Verify
```bash
source .env
curl -s -k "$BACKSTAGE_BASE_URL/api/catalog/entity-facets?facet=kind" \
-H "Authorization: Bearer $BACKSTAGE_TOKEN" \
| jq '.facets.kind'
# → [{"value": "Component", "count": 1154}, {"value": "Group", "count": 4373}, ...]
# If 401: token expired or wrong — refresh or ask your platform team.
```
**Connection details:** `tool_connections/backstage/connection-api-token.md`
---
## `.env` entries
```bash
# --- Backstage ---
BACKSTAGE_TOKEN=your-backstage-token
BACKSTAGE_BASE_URL=https://backstage.yourcompany.com
# Static token (long-lived): ask your platform team or set in app-config.yaml backend.auth.keys
# SSO JWT (short-lived ~8h): capture from browser DevTools → Application → Local Storage → backstage
```
FILE:references/tool_connections/bitbucket-server/connection-api-token.md
---
name: bitbucket-server
auth: api-token
description: Bitbucket Server / Data Center — self-hosted Git repository manager. Use when browsing projects and repos, reading file content, listing branches or commits, or searching repos by name.
env_vars:
- BITBUCKET_TOKEN
- BITBUCKET_BASE_URL
---
# Bitbucket Server / Data Center — API token (Bearer auth)
Bitbucket Server (now Bitbucket Data Center) is Atlassian's self-hosted Git repository manager. Organizes repos into projects, each identified by a short key. The REST API exposes browsing, search, branches, commits, and file content. This file covers **Server/Data Center** — distinct from Bitbucket Cloud (`bitbucket.org`), which uses a different API and OAuth2.
API docs: https://developer.atlassian.com/server/bitbucket/rest/
**Verified:** Production (Bitbucket Data Center 8.x) — `/profile/recent/repos` + `/projects` + `/projects/{key}/repos` + `/repos/{slug}/branches` + `/repos/{slug}/commits` — 2026-03. No VPN required (depends on your instance network policy).
---
## Credentials
```bash
# Add to .env:
# BITBUCKET_TOKEN=your-personal-access-token
# BITBUCKET_BASE_URL=https://bitbucket.yourcompany.com
#
# Generate token: Bitbucket → top-right user icon → Manage account → Personal access tokens → Create token
# Scopes: Project read + Repository read (add write/admin if needed)
# Token management: {BITBUCKET_BASE_URL}/plugins/servlet/access-tokens/manage
```
---
## Auth
Bearer token in the `Authorization` header:
```bash
source .env
BASE="$BITBUCKET_BASE_URL/rest/api/1.0"
# Usage: -H "Authorization: Bearer $BITBUCKET_TOKEN"
```
---
## Verify connection
```bash
source .env
BASE="$BITBUCKET_BASE_URL/rest/api/1.0"
curl -s -k -H "Authorization: Bearer $BITBUCKET_TOKEN" \
"$BASE/profile/recent/repos?limit=3" \
| jq '.values[] | {slug, name, project: .project.key}'
# → [{"slug": "my-repo", "name": "My Repo", "project": "MYPROJ"}, ...]
# If 401: token wrong or expired. If 403: token lacks read scope.
```
---
## Verified snippets
```bash
source .env
BASE="$BITBUCKET_BASE_URL/rest/api/1.0"
# Recently accessed repos (personal history — good starting point)
curl -s -k -H "Authorization: Bearer $BITBUCKET_TOKEN" \
"$BASE/profile/recent/repos?limit=5" \
| jq '.values[] | {slug, name, project: .project.key}'
# → [{"slug": "my-repo", "name": "My Repo", "project": "MYPROJ"}, ...]
# List projects (each has a short key used in all other API calls)
curl -s -k -H "Authorization: Bearer $BITBUCKET_TOKEN" \
"$BASE/projects?limit=10" \
| jq '.values[] | {key, name}'
# → [{"key": "MYPROJ", "name": "My Project"}, {"key": "PLATFORM", "name": "Platform Team"}, ...]
# List repos in a project
curl -s -k -H "Authorization: Bearer $BITBUCKET_TOKEN" \
"$BASE/projects/{PROJECT_KEY}/repos?limit=20" \
| jq '.values[] | {slug, name}'
# → [{"slug": "my-repo", "name": "My Repo"}, {"slug": "another-repo", "name": "Another Repo"}, ...]
# Search repos by name keyword
curl -s -k -H "Authorization: Bearer $BITBUCKET_TOKEN" \
"$BASE/repos?name={keyword}&limit=10" \
| jq '.values[] | {slug, name, project: .project.key}'
# → [{"slug": "my-keyword-repo", "name": "My Keyword Repo", "project": "MYPROJ"}, ...]
# List directory contents in a repo
curl -s -k -H "Authorization: Bearer $BITBUCKET_TOKEN" \
"$BASE/projects/{PROJECT_KEY}/repos/{repo-slug}/browse" \
| jq '.children.values[] | {path: .path.toString, type}'
# → [{"path": "README.md", "type": "FILE"}, {"path": "src", "type": "DIRECTORY"}, ...]
# Read a file (returns lines array)
curl -s -k -H "Authorization: Bearer $BITBUCKET_TOKEN" \
"$BASE/projects/{PROJECT_KEY}/repos/{repo-slug}/browse/{path/to/file.txt}" \
| jq -r '.lines[].text'
# → (file content, one line per entry)
# Get raw file content (plain text directly)
curl -s -k -H "Authorization: Bearer $BITBUCKET_TOKEN" \
"$BASE/projects/{PROJECT_KEY}/repos/{repo-slug}/raw/{path/to/file.txt}"
# → (raw file text)
# List branches
curl -s -k -H "Authorization: Bearer $BITBUCKET_TOKEN" \
"$BASE/projects/{PROJECT_KEY}/repos/{repo-slug}/branches?limit=10" \
| jq '.values[] | {displayId, latestCommit: .latestCommit[:8]}'
# → [{"displayId": "main", "latestCommit": "a1b2c3d4"}, {"displayId": "develop", "latestCommit": "e5f6a7b8"}, ...]
# Get recent commits
curl -s -k -H "Authorization: Bearer $BITBUCKET_TOKEN" \
"$BASE/projects/{PROJECT_KEY}/repos/{repo-slug}/commits?limit=5" \
| jq '.values[] | {id: .id[:8], message: .message[:60], author: .author.name}'
# → [{"id": "a1b2c3d4", "message": "Fix bug in auth flow", "author": "Alice"}, ...]
```
---
## Python helper
```python
import json, urllib.request, ssl
from pathlib import Path
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
BASE = env["BITBUCKET_BASE_URL"] + "/rest/api/1.0"
HEADERS = {"Authorization": f"Bearer {env['BITBUCKET_TOKEN']}"}
def bb_get(path):
req = urllib.request.Request(BASE + path, headers=HEADERS)
return json.loads(urllib.request.urlopen(req, context=ctx, timeout=10).read())
# Recent repos
repos = bb_get("/profile/recent/repos?limit=5")
for r in repos["values"]:
print(r["name"], r["project"]["key"])
```
---
## Notes
- **Server vs Cloud:** This file is for **Bitbucket Server / Data Center** (`yourcompany.com`). Bitbucket Cloud (`bitbucket.org`) uses OAuth2 and a different API base. Do not mix them.
- **Project key:** Short uppercase identifier (e.g. `MYPROJ`) — not the project name. Find it via `GET /projects`.
- **Pagination:** List endpoints return `isLastPage` and `nextPageStart`. Example loop:
```bash
start=0
while true; do
page=$(curl -s -k -H "Authorization: Bearer $BITBUCKET_TOKEN" \
"$BASE/projects/{KEY}/repos?limit=25&start=$start")
echo "$page" | jq '.values[].slug'
isLast=$(echo "$page" | jq '.isLastPage')
[ "$isLast" = "true" ] && break
start=$(echo "$page" | jq '.nextPageStart')
done
```
- **-k flag:** Enterprise instances use internal CAs — add `-k` to curl (or configure the cert) if you get SSL errors.
- **Network:** Bitbucket Server is typically internal — VPN may be required.
FILE:references/tool_connections/bitbucket-server/setup.md
---
name: bitbucket-server-setup
description: Set up Bitbucket Server / Data Center connection. Personal Access Token + Bearer auth. Ask for any repo or PR URL to infer the base URL.
---
# Bitbucket Server / Data Center — Setup
> This file covers **Bitbucket Server / Data Center** (self-hosted). Bitbucket Cloud (`bitbucket.org`) uses a different API and OAuth2 — not covered here.
## Step 1: Ask for a URL
Ask the user: "Share any Bitbucket repo or PR URL."
Infer `BITBUCKET_BASE_URL` from the URL (e.g. `https://bitbucket.yourcompany.com/projects/...` → `https://bitbucket.yourcompany.com`).
---
## Auth method: Personal Access Token (Bearer)
**What to ask the user:**
- "Paste your Bitbucket Personal Access Token" → Bitbucket → top-right user icon → Manage account → Personal access tokens → Create token
- Scopes needed: Project read + Repository read (add write/admin if needed)
---
## Set `.env`
```bash
BITBUCKET_TOKEN=your-personal-access-token
BITBUCKET_BASE_URL=https://bitbucket.yourcompany.com
```
---
## Verify
```bash
source .env
curl -s -k -H "Authorization: Bearer $BITBUCKET_TOKEN" \
"$BITBUCKET_BASE_URL/rest/api/1.0/profile/recent/repos?limit=3" \
| jq '.values[] | {slug, name, project: .project.key}'
# → [{"slug": "my-repo", "name": "My Repo", "project": "MYPROJ"}, ...]
# If 401: token wrong or expired. If 403: token lacks read scope.
```
**Connection details:** `tool_connections/bitbucket-server/connection-api-token.md`
---
## `.env` entries
```bash
# --- Bitbucket Server / Data Center ---
BITBUCKET_TOKEN=your-personal-access-token
BITBUCKET_BASE_URL=https://bitbucket.yourcompany.com
# Generate token: Bitbucket → top-right user icon → Manage account → Personal access tokens → Create token
```
FILE:references/tool_connections/confluence/connection-api-token.md
---
name: confluence
auth: api-token
description: Confluence wiki — search pages, fetch content, browse spaces. Use when looking up internal documentation, runbooks, architecture pages, procedures, or any content stored in Confluence.
env_vars:
- CONFLUENCE_TOKEN
- CONFLUENCE_EMAIL
- CONFLUENCE_BASE_URL
---
# Confluence
Env: `CONFLUENCE_TOKEN`, `CONFLUENCE_EMAIL`, `CONFLUENCE_BASE_URL`
**Confluence Cloud** uses HTTP Basic auth: `email:api-token` (NOT Bearer).
**Confluence Server/Data Center** uses Bearer token (Personal Access Token).
```bash
# Set in .env:
# [email protected] # Atlassian account email
# CONFLUENCE_TOKEN=your-atlassian-api-token # Same token as JIRA_API_TOKEN if using same account
# CONFLUENCE_BASE_URL=https://yourcompany.atlassian.net/wiki # Confluence Cloud
# CONFLUENCE_BASE_URL=https://confluence.yourcompany.com # Confluence Server/Data Center
```
**Generate token:** https://id.atlassian.com/manage-profile/security/api-tokens → Create API token
For Confluence Cloud, the token is the same as your Jira API token (same Atlassian account).
## Verify connection
```bash
source .env
# Confluence Cloud (Basic auth with email:token)
curl -s -u "$CONFLUENCE_EMAIL:$CONFLUENCE_TOKEN" \
"$CONFLUENCE_BASE_URL/rest/api/content/search?cql=type=page&limit=1" \
| jq '{total: .size, first: .results[0].title}'
# → {"total": 1, "first": "Template - Decision documentation"}
# Confluence Server/Data Center (Bearer token)
# curl -s -H "Authorization: Bearer $CONFLUENCE_TOKEN" \
# "$CONFLUENCE_BASE_URL/rest/api/content/search?cql=type=page&limit=1" \
# | jq '{total: .size, first: .results[0].title}'
```
**Verified:** Production (get10xproductivity.atlassian.net/wiki) — /rest/api/content/search + /rest/api/space — 2026-03-18. No VPN required.
---
## Search pages
```bash
source .env
# Search by title
curl -s -u "$CONFLUENCE_EMAIL:$CONFLUENCE_TOKEN" \
"$CONFLUENCE_BASE_URL/rest/api/content/search?cql=title~%22<keyword>%22&limit=5&expand=space" \
| jq '.results[] | {id, title, space: .space.key}'
# Search body text
curl -s -u "$CONFLUENCE_EMAIL:$CONFLUENCE_TOKEN" \
"$CONFLUENCE_BASE_URL/rest/api/content/search?cql=text~%22<keyword>%22&limit=5&expand=space" \
| jq '.results[] | {id, title, space: .space.key}'
# Search by title AND body keyword
curl -s -u "$CONFLUENCE_EMAIL:$CONFLUENCE_TOKEN" \
"$CONFLUENCE_BASE_URL/rest/api/content/search?cql=title~%22<keyword1>%22+AND+text~%22<keyword2>%22&limit=5&expand=space" \
| jq '.results[] | {id, title, space: .space.key}'
# Search in a specific space
curl -s -u "$CONFLUENCE_EMAIL:$CONFLUENCE_TOKEN" \
"$CONFLUENCE_BASE_URL/rest/api/content/search?cql=space=%22MYSPACE%22+AND+text~%22<keyword>%22&limit=10" \
| jq '.results[] | {id, title}'
```
---
## Fetch page content
```bash
source .env
# Fetch a page by ID (strip HTML tags for readable text)
curl -s -u "$CONFLUENCE_EMAIL:$CONFLUENCE_TOKEN" \
"$CONFLUENCE_BASE_URL/rest/api/content/<PAGE_ID>?expand=body.view" \
| jq -r '.body.view.value' | sed 's/<[^>]*>//g' | tr -s ' \n' | head -c 3000
# Get page metadata (title, space, version, last modified)
curl -s -u "$CONFLUENCE_EMAIL:$CONFLUENCE_TOKEN" \
"$CONFLUENCE_BASE_URL/rest/api/content/<PAGE_ID>?expand=version,space" \
| jq '{id, title, space: .space.key, version: .version.number, lastModified: .version.when}'
# Get child pages
curl -s -u "$CONFLUENCE_EMAIL:$CONFLUENCE_TOKEN" \
"$CONFLUENCE_BASE_URL/rest/api/content/<PAGE_ID>/child/page?limit=20" \
| jq '.results[] | {id, title}'
```
---
## Browse spaces
```bash
source .env
# List all spaces
curl -s -u "$CONFLUENCE_EMAIL:$CONFLUENCE_TOKEN" \
"$CONFLUENCE_BASE_URL/rest/api/space?limit=25&expand=description.plain" \
| jq '.results[] | {key, name, description: .description.plain.value}'
# Get a space's homepage
curl -s -u "$CONFLUENCE_EMAIL:$CONFLUENCE_TOKEN" \
"$CONFLUENCE_BASE_URL/rest/api/space/<SPACE_KEY>?expand=homepage" \
| jq '{key, name, homepage: .homepage.id}'
# List all pages in a space
curl -s -u "$CONFLUENCE_EMAIL:$CONFLUENCE_TOKEN" \
"$CONFLUENCE_BASE_URL/rest/api/space/<SPACE_KEY>/content?limit=25" \
| jq '.page.results[] | {id, title}'
```
---
## CQL (Confluence Query Language) reference
| Goal | CQL |
|------|-----|
| Page by title | `title = "Exact Title"` |
| Title contains keyword | `title ~ "keyword"` |
| Body contains text | `text ~ "keyword"` |
| In specific space | `space = "SPACEKEY"` |
| Updated after date | `lastModified > "2026-01-01"` |
| By a specific author | `creator = "username"` |
| Pages only (not blog posts) | `type = "page"` |
| Combine filters | `space = "ENG" AND text ~ "deployment" AND lastModified > "2026-01-01"` |
FILE:references/tool_connections/confluence/setup.md
---
name: confluence-setup
description: Set up Confluence connection. Supports Cloud (API token + Basic auth) and Server/Data Center (Bearer PAT). Ask for any Confluence page URL to infer base URL and variant.
---
# Confluence — Setup
## Step 1: Ask for a URL to identify the variant
Ask the user: "Share any Confluence page URL."
Infer variant from the URL:
- `yourcompany.atlassian.net/wiki` → **Confluence Cloud** → API token + Basic auth (`connection-api-token.md`)
- `confluence.yourcompany.com` (self-hosted) → **Confluence Server/Data Center** → Bearer PAT (`connection-server-pat.md`)
Infer `CONFLUENCE_BASE_URL` from the URL (e.g. `https://acme.atlassian.net/wiki/spaces/...` → `https://acme.atlassian.net/wiki`).
---
## Confluence Cloud — API token (most common)
**What to ask the user:**
- "Paste your Confluence API token" → https://id.atlassian.com/manage-profile/security/api-tokens → Create API token
- "Your Atlassian account email"
> **Note:** Confluence Cloud and Jira Cloud share the same Atlassian account. If the user already set up Jira, the same token and email work for Confluence.
**Set `.env`:**
```bash
[email protected]
CONFLUENCE_TOKEN=your-atlassian-api-token
CONFLUENCE_BASE_URL=https://yourcompany.atlassian.net/wiki # inferred from URL they shared
```
**Verify:**
```bash
source .env
curl -s -u "$CONFLUENCE_EMAIL:$CONFLUENCE_TOKEN" \
"$CONFLUENCE_BASE_URL/rest/api/content/search?cql=type=page&limit=1" \
| jq '{total: .size, first: .results[0].title}'
# → {"total": 1, "first": "Some Page Title"}
# If 401: wrong email or token. If 403: token lacks permissions.
```
**Connection details:** `tool_connections/confluence/connection-api-token.md`
---
## Confluence Server / Data Center — Personal Access Token
**What to ask the user:**
- "Paste your Confluence Personal Access Token" → Confluence → Profile → Personal Access Tokens → Create token
**Set `.env`:**
```bash
CONFLUENCE_TOKEN=your-personal-access-token
CONFLUENCE_BASE_URL=https://confluence.yourcompany.com # inferred from URL they shared
```
**Verify:**
```bash
source .env
curl -s -H "Authorization: Bearer $CONFLUENCE_TOKEN" \
"$CONFLUENCE_BASE_URL/rest/api/content/search?cql=type=page&limit=1" \
| jq '{total: .size, first: .results[0].title}'
```
**Connection details:** `tool_connections/confluence/connection-server-pat.md` *(coming soon)*
---
## `.env` entries
```bash
# --- Confluence ---
[email protected]
CONFLUENCE_TOKEN=your-atlassian-api-token
CONFLUENCE_BASE_URL=https://yourcompany.atlassian.net/wiki
# For Confluence Server: CONFLUENCE_BASE_URL=https://confluence.yourcompany.com
# Generate token at: https://id.atlassian.com/manage-profile/security/api-tokens
```
FILE:references/tool_connections/datadog/connection-api-key.md
---
name: datadog
auth: api-key
description: Datadog — cloud monitoring platform for metrics, APM, logs, dashboards, and incidents. Use when querying monitors and alerts, checking host inventory, searching metrics time-series, listing dashboards, or looking up active incidents.
env_vars:
- DD_API_KEY
- DD_APP_KEY
- DD_BASE_URL
---
# Datadog
Cloud monitoring platform covering metrics, APM traces, logs, dashboards, and incident management. Used by SRE and engineering teams to observe service health and respond to incidents.
Env: `DD_API_KEY` + `DD_APP_KEY` (long-lived — no expiry by default)
API docs: https://docs.datadoghq.com/api/latest/
**Verified:** Production (api.us5.datadoghq.com) — `/api/v1/validate`, `/api/v1/monitor`, `/api/v1/hosts`, `/api/v1/dashboard`, `/api/v1/metrics`, `/api/v1/query`, `/api/v2/incidents` — 2026-03. No VPN required.
---
## Auth setup (one-time)
1. Find your site subdomain from the Datadog UI URL (e.g. `us5.datadoghq.com` → `DD_BASE_URL=https://api.us5.datadoghq.com`)
2. Go to `https://{your-site}/organization-settings/api-keys` → **New Key**
3. Go to `https://{your-site}/organization-settings/application-keys` → **New Key**
4. Add to `.env`:
```bash
# --- Datadog ---
DD_API_KEY=your-api-key-here
DD_APP_KEY=your-application-key-here
DD_BASE_URL=https://api.us5.datadoghq.com # change to match your site
```
Auth: `DD-API-KEY` header for all requests; `DD-APPLICATION-KEY` header for read endpoints.
## Verify connection
```bash
source .env
curl -s "$DD_BASE_URL/api/v1/validate" \
-H "DD-API-KEY: $DD_API_KEY" \
| jq .
# → {"valid": true}
```
---
## Quick-reference snippets
```bash
source .env
BASE="$DD_BASE_URL"
# List monitors
curl -s "$BASE/api/v1/monitor?page=0&page_size=10" \
-H "DD-API-KEY: $DD_API_KEY" \
-H "DD-APPLICATION-KEY: $DD_APP_KEY" \
| jq '[.[] | {id, name, type, overall_state}]'
# → [] on fresh org; [{...}] when monitors exist
# Filter to alerting monitors only
curl -s "$BASE/api/v1/monitor?page=0&page_size=50" \
-H "DD-API-KEY: $DD_API_KEY" \
-H "DD-APPLICATION-KEY: $DD_APP_KEY" \
| jq '[.[] | select(.overall_state == "Alert") | {id, name, overall_state}]'
# → [] or [{id: 12345, name: "CPU spike", overall_state: "Alert"}]
# List hosts
curl -s "$BASE/api/v1/hosts?count=10&start=0" \
-H "DD-API-KEY: $DD_API_KEY" \
-H "DD-APPLICATION-KEY: $DD_APP_KEY" \
| jq '{total_returned, host_list: [.host_list[]? | {name, up, apps}]}'
# → {"total_returned": 0, "host_list": []}
# List dashboards
curl -s "$BASE/api/v1/dashboard" \
-H "DD-API-KEY: $DD_API_KEY" \
-H "DD-APPLICATION-KEY: $DD_APP_KEY" \
| jq '{total: (.dashboards | length), sample: [.dashboards[:5][]? | {id, title}]}'
# → {"total": 0, "sample": []}
# List active metrics (last hour)
curl -s "$BASE/api/v1/metrics?from=$(($(date +%s) - 3600))" \
-H "DD-API-KEY: $DD_API_KEY" \
-H "DD-APPLICATION-KEY: $DD_APP_KEY" \
| jq '{metrics_count: (.metrics | length), sample: .metrics[:5]}'
# → {"metrics_count": 9, "sample": ["datadog.apis.usage.per_org", ...]}
# Query a metric time-series (last 1 hour)
# IMPORTANT: {*} must be URL-encoded as %7B*%7D — bare braces cause a parse error
NOW=$(date +%s); FROM=$((NOW - 3600))
METRIC="avg:datadog.apis.usage.per_org%7B*%7D" # replace with any metric from list above
curl -s "$BASE/api/v1/query?from=$FROM&to=$NOW&query=$METRIC" \
-H "DD-API-KEY: $DD_API_KEY" \
-H "DD-APPLICATION-KEY: $DD_APP_KEY" \
| jq '{status, series_count: (.series | length), sample_points: (.series[0].pointlist[-3:] // [])}'
# → {"status": "ok", "series_count": 1, "sample_points": [[1773860420000.0, 1.75], ...]}
# List incidents (page brackets must also be URL-encoded)
curl -s "$BASE/api/v2/incidents?page%5Bsize%5D=10" \
-H "DD-API-KEY: $DD_API_KEY" \
-H "DD-APPLICATION-KEY: $DD_APP_KEY" \
| jq '{data_count: (.data | length), pagination: .meta.pagination}'
# → {"data_count": 0, "pagination": {"offset": 0, "next_offset": 0, "size": 0}}
```
---
## Full API surface
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/validate` | Validate API key (no app key needed) |
| GET | `/api/v1/monitor` | List monitors (`?page`, `?page_size`, `?monitor_tags`) |
| GET | `/api/v1/monitor/{id}` | Single monitor detail |
| GET | `/api/v1/hosts` | Host inventory (`?count`, `?start`, `?filter`, `?sort_field`) |
| GET | `/api/v1/dashboard` | List all dashboards |
| GET | `/api/v1/dashboard/{id}` | Single dashboard with widgets |
| GET | `/api/v1/metrics` | List active metric names (`?from=<unix_ts>`) |
| GET | `/api/v1/query` | Query metric time-series (`?from`, `?to`, `?query`) |
| GET | `/api/v2/incidents` | List incidents (`?page%5Bsize%5D=`, `?filter%5Bstatus%5D=`) |
| GET | `/api/v2/incidents/{id}` | Single incident detail |
---
## Notes
- **Site-specific base URLs** — match `DD_BASE_URL` to your org's subdomain:
- US1 (default): `https://api.datadoghq.com`
- US3: `https://api.us3.datadoghq.com`
- US5: `https://api.us5.datadoghq.com`
- EU: `https://api.datadoghq.eu`
- AP1: `https://api.ap1.datadoghq.com`
- Gov: `https://api.ddog-gov.com`
- **URL-encoding required in curl:** `{*}` → `%7B*%7D`; `[size]` → `%5Bsize%5D`. Bare braces cause `Rule 'scope_expr' didn't match` parse errors.
- **Empty results are valid:** fresh orgs return `[]` / `{}` for monitors, hosts, dashboards — not an error.
- **Application key is user-scoped** — inherits the creating user's permissions.
- **No VPN required** — all endpoints are public SaaS.
- **Rate limits** in response headers: `x-ratelimit-remaining`, `x-ratelimit-reset` (default ~100 req/60s).
FILE:references/tool_connections/datadog/setup.md
---
name: datadog-setup
description: Set up Datadog connection. API key + Application key. Ask for Datadog URL to infer the regional base URL.
---
# Datadog — Setup
## Auth method: API key + Application key
**What to ask the user:**
- "Share your Datadog URL" → infer `DD_BASE_URL` from the subdomain
- "Paste your Datadog API key" → `https://{your-site}/organization-settings/api-keys` → New Key
- "Paste your Datadog Application key" → `https://{your-site}/organization-settings/application-keys` → New Key
---
## Step 1: Infer base URL from their Datadog URL
| URL subdomain | DD_BASE_URL |
|--------------|-------------|
| `app.datadoghq.com` | `https://api.datadoghq.com` |
| `us3.datadoghq.com` | `https://api.us3.datadoghq.com` |
| `us5.datadoghq.com` | `https://api.us5.datadoghq.com` |
| `app.datadoghq.eu` | `https://api.datadoghq.eu` |
| `ap1.datadoghq.com` | `https://api.ap1.datadoghq.com` |
| `ddog-gov.com` | `https://api.ddog-gov.com` |
---
## Set `.env`
```bash
DD_API_KEY=your-api-key-here
DD_APP_KEY=your-application-key-here
DD_BASE_URL=https://api.us5.datadoghq.com # change to match your site
```
---
## Verify
```bash
source .env
curl -s "$DD_BASE_URL/api/v1/validate" \
-H "DD-API-KEY: $DD_API_KEY" \
| jq .
# → {"valid": true}
# If {"valid": false}: wrong API key or wrong base URL
```
**Connection details:** `tool_connections/datadog/connection-api-key.md`
---
## `.env` entries
```bash
# --- Datadog ---
DD_API_KEY=your-datadog-api-key
DD_APP_KEY=your-datadog-application-key
DD_BASE_URL=https://api.datadoghq.com
# US5: https://api.us5.datadoghq.com / EU: https://api.datadoghq.eu (match your site subdomain)
# API key: https://{your-site}/organization-settings/api-keys → New Key
# App key: https://{your-site}/organization-settings/application-keys → New Key
```
FILE:references/tool_connections/github/connection-api-token.md
---
name: github
auth: api-token
description: GitHub — browse repos, fetch READMEs and API docs, search code, manage PRs and issues. Use when browsing repos, reading READMEs, searching code, creating or reviewing PRs, managing issues. Works with both github.com and GitHub Enterprise (self-hosted).
env_vars:
- GITHUB_TOKEN
- GITHUB_BASE_URL
---
# GitHub
Env: `GITHUB_TOKEN`, `GITHUB_BASE_URL`
```bash
# Set in .env:
# GITHUB_TOKEN=ghp_your-personal-access-token
# GITHUB_BASE_URL=https://api.github.com # public GitHub
# GITHUB_BASE_URL=https://your-ghe.example.com/api/v3 # GitHub Enterprise
```
Auth: `Authorization: token $GITHUB_TOKEN`
**Generate token:** GitHub → Settings → Developer settings → Personal access tokens → Generate new token
Scopes needed: `repo`, `read:org` (add `workflow` if you need to trigger Actions)
## Verify connection
```bash
source .env
curl -s -H "Authorization: token $GITHUB_TOKEN" \
"$GITHUB_BASE_URL/user" \
| jq '{login, name, email}'
# → {"login": "alice", "name": "Alice Smith", "email": "[email protected]"}
# If you see 401: token is wrong. If you see 404: check GITHUB_BASE_URL.
```
---
## Repos
```bash
source .env
# List your repos (sorted by recently updated)
curl -s -H "Authorization: token $GITHUB_TOKEN" \
"$GITHUB_BASE_URL/user/repos?per_page=10&sort=updated" \
| jq '.[] | {name, full_name, updated_at, description}'
# Search repos by keyword
curl -s -H "Authorization: token $GITHUB_TOKEN" \
"$GITHUB_BASE_URL/search/repositories?q=<keyword>&per_page=5" \
| jq '.items[] | {name, full_name, description, stargazers_count}'
# Fetch a repo's README (base64-decoded)
curl -s -H "Authorization: token $GITHUB_TOKEN" \
"$GITHUB_BASE_URL/repos/{owner}/{repo}/readme" \
| jq -r '.content' | base64 -d
# List directory contents
curl -s -H "Authorization: token $GITHUB_TOKEN" \
"$GITHUB_BASE_URL/repos/{owner}/{repo}/contents/{path}" \
| jq '.[] | {name, type, path}'
# Fetch a specific file (base64-decoded)
curl -s -H "Authorization: token $GITHUB_TOKEN" \
"$GITHUB_BASE_URL/repos/{owner}/{repo}/contents/{path/to/file}" \
| jq -r '.content' | base64 -d
```
---
## Code search
```bash
source .env
# Search code by keyword
curl -s -H "Authorization: token $GITHUB_TOKEN" \
"$GITHUB_BASE_URL/search/code?q=<keyword>+repo:{owner}/{repo}&per_page=5" \
| jq '.items[] | {path, name, repository: .repository.full_name}'
# Search code across all accessible repos
curl -s -H "Authorization: token $GITHUB_TOKEN" \
"$GITHUB_BASE_URL/search/code?q=<keyword>&per_page=10" \
| jq '.items[] | {path, repository: .repository.full_name}'
```
---
## Pull Requests
```bash
source .env
# List open PRs in a repo
curl -s -H "Authorization: token $GITHUB_TOKEN" \
"$GITHUB_BASE_URL/repos/{owner}/{repo}/pulls?state=open&per_page=20" \
| jq '.[] | {number, title, user: .user.login, created_at}'
# Get a specific PR
curl -s -H "Authorization: token $GITHUB_TOKEN" \
"$GITHUB_BASE_URL/repos/{owner}/{repo}/pulls/{pr_number}" \
| jq '{number, title, state, body, user: .user.login, base: .base.ref, head: .head.ref}'
# Get PR review comments
curl -s -H "Authorization: token $GITHUB_TOKEN" \
"$GITHUB_BASE_URL/repos/{owner}/{repo}/pulls/{pr_number}/comments" \
| jq '.[] | {user: .user.login, body, path, line}'
# Create a PR
curl -s -X POST -H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/json" \
"$GITHUB_BASE_URL/repos/{owner}/{repo}/pulls" \
-d '{"title": "PR title", "body": "Description", "head": "feature-branch", "base": "main"}'
```
---
## Issues
```bash
source .env
# List open issues
curl -s -H "Authorization: token $GITHUB_TOKEN" \
"$GITHUB_BASE_URL/repos/{owner}/{repo}/issues?state=open&per_page=20" \
| jq '.[] | {number, title, user: .user.login, labels: [.labels[].name]}'
# Create an issue
curl -s -X POST -H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/json" \
"$GITHUB_BASE_URL/repos/{owner}/{repo}/issues" \
-d '{"title": "Issue title", "body": "Issue description", "labels": ["bug"]}'
# Add a comment to an issue
curl -s -X POST -H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/json" \
"$GITHUB_BASE_URL/repos/{owner}/{repo}/issues/{issue_number}/comments" \
-d '{"body": "Comment text here."}'
```
---
## Commits and branches
```bash
source .env
# List branches
curl -s -H "Authorization: token $GITHUB_TOKEN" \
"$GITHUB_BASE_URL/repos/{owner}/{repo}/branches?per_page=20" \
| jq '.[] | .name'
# Get recent commits
curl -s -H "Authorization: token $GITHUB_TOKEN" \
"$GITHUB_BASE_URL/repos/{owner}/{repo}/commits?per_page=10" \
| jq '.[] | {sha: .sha[:8], message: .commit.message, author: .commit.author.name, date: .commit.author.date}'
```
FILE:references/tool_connections/github/setup.md
---
name: github-setup
description: Set up GitHub connection. API token (PAT) for github.com and GitHub Enterprise. Ask for a repo or PR URL if using GitHub Enterprise to infer the base URL.
---
# GitHub — Setup
## Step 1: Identify the variant
- No URL provided, or URL is `github.com` → **GitHub.com** — base URL is always `https://api.github.com`
- URL is a self-hosted instance (e.g. `github.yourcompany.com`) → **GitHub Enterprise** — infer base URL as `https://github.yourcompany.com/api/v3`
---
## Auth method: Personal Access Token (PAT)
**What to ask the user:**
- "Paste your GitHub personal access token" → GitHub → Settings → Developer settings → Personal access tokens → Generate new token
- Scopes needed: `repo`, `read:org` (add `workflow` to trigger Actions)
- If GitHub Enterprise: "Share any repo or PR URL from your GitHub" to infer the base URL
**Set `.env`:**
```bash
GITHUB_TOKEN=ghp_your-personal-access-token
GITHUB_BASE_URL=https://api.github.com # or https://your-ghe.example.com/api/v3
```
**Verify:**
```bash
source .env
curl -s -H "Authorization: token $GITHUB_TOKEN" \
"$GITHUB_BASE_URL/user" \
| jq '{login, name, email}'
# → {"login": "alice", "name": "Alice Smith", "email": "[email protected]"}
# If 401: token is wrong. If 404: check GITHUB_BASE_URL.
```
**Connection details:** `tool_connections/github/connection-api-token.md`
---
## `.env` entries
```bash
# --- GitHub ---
GITHUB_TOKEN=ghp_your-personal-access-token
GITHUB_BASE_URL=https://api.github.com
# For GitHub Enterprise: GITHUB_BASE_URL=https://your-ghe.example.com/api/v3
# Generate token at: GitHub → Settings → Developer settings → Personal access tokens
```
FILE:references/tool_connections/google-drive/connection-browser-session.md
---
name: google-drive
auth: browser-session
description: Google Drive via Playwright storage_state session — no OAuth app needed. Use when you need to list, search, read, or export files from Google Drive. Supports listing My Drive, searching by keyword/owner, and exporting Google Docs/Sheets/Slides as text/CSV.
env_vars: []
auth_file: ~/.browser_automation/gdrive_auth.json
---
# Google Drive
No OAuth app or admin approval needed — access is via a saved Playwright browser session, using `storage_state` to persist the Google Workspace SSO cookie bundle.
**Auth notes:**
- Google cookies are tied to the browser fingerprint — raw cookie injection triggers `CookieMismatch`. Must use Playwright's `storage_state` to replay the full session.
- Cookie lifetime: days to weeks. Re-run `--gdrive-only` only when you get auth errors.
- Requires a headed browser (macOS enterprise SSO only fires with a UI context).
Auth file: `~/.browser_automation/gdrive_auth.json` (Playwright storage_state snapshot)
Refresh: `python3 tool_connections/shared_utils/playwright_sso.py --gdrive-only`
Asset: `assets/google_drive.py` — importable `GDrive` class; use instead of writing boilerplate
## Verify connection
```python
import sys; sys.path.insert(0, "tool_connections/google-drive")
from google_drive import GDrive
with GDrive() as drive:
files = drive.list_my_drive()
print(f"{len(files)} files in My Drive")
for f in files[:3]:
print(f" [{f['type']}] {f['name']}")
# Should list 3 files from your Drive
# If you see an auth error: re-run playwright_sso.py --gdrive-only to refresh the session.
```
---
## Quick start (use the asset — no boilerplate)
```python
import sys
sys.path.insert(0, "tool_connections/google-drive")
from google_drive import GDrive
with GDrive() as drive:
results = drive.search("meeting notes") # search by keyword
files = drive.list_my_drive() # My Drive root
folder = drive.list_folder(folder_id) # specific folder
content = drive.read(file_id, "document") # export Doc as plain text
csv = drive.read(file_id, "spreadsheet") # export Sheet as CSV
notes = drive.read(file_id, "presentation") # export Slides as text
# Write to a specific cell (row, col are 1-indexed)
drive.write_sheet_cell(sheet_id, row=10, col=2, value="new value")
# Find a row by value and write to it (safer than hardcoding row numbers)
row = drive.find_row_and_write(
sheet_id,
search_col=1, search_value="target value",
write_col=2, write_value="new value",
)
```
**Write rule:** Always use `find_row_and_write` when writing to a sheet whose row layout may change. Only use `write_sheet_cell` when you know the exact row from a prior read.
CLI (from the assets folder):
```bash
python3 google_drive.py search "keyword"
python3 google_drive.py ls
python3 google_drive.py read <file_id> document
```
---
## Setup (once, or when session expires)
```bash
source .venv/bin/activate
python3 tool_connections/shared_utils/playwright_sso.py --gdrive-only
```
Browser opens → Google Workspace SSO completes → session saved to `~/.browser_automation/gdrive_auth.json`.
---
## Core helper functions
```python
import re, time
from pathlib import Path
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
AUTH_FILE = Path.home() / ".browser_automation" / "gdrive_auth.json"
_ID_PATTERNS = [
r"/document/d/([a-zA-Z0-9_-]{20,})",
r"/spreadsheets/d/([a-zA-Z0-9_-]{20,})",
r"/presentation/d/([a-zA-Z0-9_-]{20,})",
r"/file/d/([a-zA-Z0-9_-]{20,})",
r"/folders/([a-zA-Z0-9_-]{20,})",
]
def _extract_id(link: str) -> str | None:
for pat in _ID_PATTERNS:
m = re.search(pat, link)
if m: return m.group(1)
return None
def _infer_type(link: str) -> str:
if "/document/d/" in link: return "document"
if "/spreadsheets/d/" in link: return "spreadsheet"
if "/presentation/d/" in link: return "presentation"
if "/folders/" in link: return "folder"
return "file"
def gdrive_extract_files(page) -> list[dict]:
"""
Extract file list from current Drive page DOM.
Returns list of {id, name, type} where id is the full 44-char file ID.
IMPORTANT: data-id attributes in the DOM are truncated (~30 chars).
This function uses href attributes to get the full ID.
"""
raw = page.evaluate("""() => {
const files = []; const seen = new Set();
document.querySelectorAll('[data-id]').forEach(el => {
const dataId = el.getAttribute('data-id') || '';
const name = el.querySelector('[data-tooltip]')?.getAttribute('data-tooltip')
|| el.getAttribute('data-tooltip') || '';
const links = Array.from(el.querySelectorAll('a[href]'))
.map(a => a.getAttribute('href')).filter(Boolean);
files.push({ dataId, name: name.trim(), links });
});
return files;
}""")
suffixes = {
"Google Docs": "document", "Google Sheets": "spreadsheet",
"Google Slides": "presentation", "Google Forms": "form",
"Shared folder": "folder", "Folder": "folder",
}
result = []; seen = set()
for f in raw:
best_id = f["dataId"]; best_link = ""
for link in f["links"]:
fid = _extract_id(link)
if fid and len(fid) > len(best_id):
best_id = fid; best_link = link
if not best_id or len(best_id) < 15 or best_id in seen: continue
seen.add(best_id)
ftype = _infer_type(best_link) if best_link else "file"
clean = f["name"]
for suffix, t in suffixes.items():
if f["name"].endswith(suffix):
clean = f["name"][:-len(suffix)].strip()
if ftype == "file": ftype = t
break
result.append({"id": best_id, "name": clean, "type": ftype})
return result
def gdrive_search(page, query: str) -> list[dict]:
"""
Search Drive by navigating to the search URL.
query: plain text, or Drive search operators:
owner:me — files you own
'meeting notes' — exact phrase
"""
import urllib.parse
try:
page.goto(f"https://drive.google.com/drive/search?q={urllib.parse.quote(query)}",
wait_until="networkidle", timeout=30_000)
except PlaywrightTimeout:
pass
time.sleep(1)
return gdrive_extract_files(page)
def gdrive_export(page, file_id: str, file_type: str) -> str:
"""
Export a Google file and return its text content.
file_type: 'document' → plain text, 'spreadsheet' → CSV, 'presentation' → text
"""
urls = {
"document": f"https://docs.google.com/document/d/{file_id}/export?format=txt",
"spreadsheet": f"https://docs.google.com/spreadsheets/d/{file_id}/export?format=csv",
"presentation": f"https://docs.google.com/presentation/d/{file_id}/export/txt",
}
url = urls.get(file_type, "")
if not url:
return f"(unsupported type: {file_type})"
with page.expect_download(timeout=25_000) as dl_info:
try:
page.goto(url, wait_until="commit", timeout=10_000)
except Exception:
pass
download = dl_info.value
content = Path(download.path()).read_text(errors="replace")
return content
```
---
## Usage pattern (open browser once, do all operations)
```python
with sync_playwright() as p:
browser = p.chromium.launch(
headless=False, # must be headed — Google Workspace SSO requires it
args=["--window-size=1400,900"],
)
ctx = browser.new_context(
storage_state=str(AUTH_FILE),
ignore_https_errors=True,
accept_downloads=True,
)
page = ctx.new_page()
try:
page.goto("https://drive.google.com/drive/my-drive", wait_until="networkidle", timeout=45_000)
except PlaywrightTimeout:
pass
time.sleep(2)
# List My Drive
files = gdrive_extract_files(page)
for f in files[:10]:
print(f" [{f['type']:<14}] {f['name']}")
# Search
results = gdrive_search(page, "project proposal")
# Export a Google Doc as text
docs = [f for f in results if f["type"] == "document"]
if docs:
text = gdrive_export(page, docs[0]["id"], "document")
print(text[:500])
browser.close()
```
---
## Search query syntax
| Goal | query string |
|------|-------------|
| Keyword in name | `meeting notes` |
| Files you own | `owner:me` |
| Files by a specific person | `owner:[email protected]` |
| Files shared with you | `sharedwith:me` |
| Combine keyword + owner | `owner:me project proposal` |
---
## File types
| Drive shows | type value | Export format |
|-------------|-----------|---------------|
| Google Docs | `document` | plain text |
| Google Sheets | `spreadsheet` | CSV |
| Google Slides | `presentation` | text (slide titles + notes) |
| Folders | `folder` | N/A |
| Other (PDF, etc.) | `file` | N/A |
---
## Caveats
- **headless=False required** — Google Workspace SSO only fires in a headed browser.
- **Exports do NOT go to ~/Downloads** — `accept_downloads=True` intercepts downloads to a Playwright temp dir. `download.path()` gives the temp path directly.
- **Session lifetime**: days to weeks. Re-run `playwright_sso.py --gdrive-only` if Drive redirects to sign-in.
- **`data-id` is truncated** — Drive's DOM `data-id` attributes are ~30 chars; the real file ID is 44 chars. Always use `gdrive_extract_files()` which gets the full ID from the href.
- **Export requires access** — `gdrive_export()` only works for files you can open. Use `owner:me` search for files guaranteed to export.
- **~51-item render cap per folder** — Drive's virtual DOM only renders ~51 items in the initial viewport. Folders with more items will be undercounted without programmatic scrolling.
FILE:references/tool_connections/google-drive/setup.md
---
name: google-drive-setup
description: Set up Google Drive connection. Playwright browser session (storage_state). No input needed from the user — just run the script and log in once.
---
# Google Drive — Setup
## Auth method: Playwright browser session
Google Drive requires a full browser session (Playwright `storage_state`) rather than a cookie injection, because Google detects raw cookie injection and shows a security warning. The session is saved to `~/.browser_automation/gdrive_auth.json` and is valid for days to weeks.
**What to ask the user:** Nothing. Just run the script — the browser opens and the user logs in to Google once if not already logged in.
---
## Steps
```bash
source .venv/bin/activate
python3 tool_connections/shared_utils/playwright_sso.py --gdrive-only
# Opens a browser — log in to Google if prompted (~30s)
# Session saved to ~/.browser_automation/gdrive_auth.json
```
---
## Verify
```python
import sys; sys.path.insert(0, "tool_connections/google-drive")
from google_drive import GDrive
with GDrive() as drive:
files = drive.list_my_drive()
print(f"{len(files)} files in My Drive")
for f in files[:3]:
print(f" [{f['type']}] {f['name']}")
# Should list files from your Drive
# If auth error: re-run playwright_sso.py --gdrive-only to refresh the session
```
**Connection details:** `tool_connections/google-drive/connection-browser-session.md`
---
## Refresh
Sessions last days to weeks. When expired (Drive redirects to sign-in):
```bash
source .venv/bin/activate
python3 tool_connections/shared_utils/playwright_sso.py --gdrive-only
```
FILE:references/tool_connections/grafana/connection-sso.md
---
name: grafana
auth: sso-session
description: Grafana dashboards — extract PromQL queries from panels, look up dashboard UIDs, query data. Use when you need the PromQL from a Grafana dashboard (e.g. for incident analysis), or want to find which dashboards exist for a service.
env_vars:
- GRAFANA_BASE_URL
- GRAFANA_SESSION
---
# Grafana
Env: `GRAFANA_BASE_URL`, `GRAFANA_SESSION`
```bash
# Set in .env:
# GRAFANA_BASE_URL=https://grafana.yourcompany.com
# GRAFANA_SESSION=your-grafana-session-cookie-value (~8h, refresh with playwright_sso.py)
```
Auth: session cookie captured via SSO — refresh with the shared script (see below).
**The primary use case is extracting PromQL:** Grafana dashboard JSON contains all panel queries with Grafana variable placeholders (e.g. `env`). Substitute variables to get runnable PromQL, then execute via your Prometheus-compatible endpoint.
## Verify connection
```bash
source .env
curl -s "$GRAFANA_BASE_URL/api/user" \
-H "Cookie: grafana_session=$GRAFANA_SESSION" \
| jq '{login, email, name}'
# → {"login": "alice", "email": "[email protected]", "name": "Alice Smith"}
# If you see 401/redirect: session expired — run playwright_sso.py to refresh.
# If you see connection refused: check GRAFANA_BASE_URL in .env.
```
---
## Refresh session
```bash
# Refreshes GRAFANA_SESSION — opens browser for SSO, ~20–30 s
source .venv/bin/activate
python3 tool_connections/shared_utils/playwright_sso.py
```
---
## Get PromQL from a dashboard
```bash
source .env
# Get full dashboard JSON — includes all panels and their PromQL targets
curl -s "$GRAFANA_BASE_URL/api/dashboards/uid/{uid}" \
-H "Cookie: grafana_session=$GRAFANA_SESSION" \
| jq '[.dashboard.panels[] | select(.targets != null)
| {title, exprs: [.targets[]? | select(.expr) | .expr]}
| select(.exprs | length > 0)]'
# Shorter version — first 10 panels, truncated expressions
curl -s "$GRAFANA_BASE_URL/api/dashboards/uid/{uid}" \
-H "Cookie: grafana_session=$GRAFANA_SESSION" \
| jq '[.dashboard.panels[] | select(.targets != null)
| {title, exprs: [.targets[]? | select(.expr) | .expr[:120]]}
| select(.exprs | length > 0)][:10]'
```
**Variable substitution:** Panel PromQL uses Grafana template variables like `env` or `$service`. Replace with actual values before running:
```python
import re
def substitute_vars(expr: str, vars: dict) -> str:
"""Replace Grafana var and $var placeholders with actual values."""
for k, v in vars.items():
expr = re.sub(rf'\{{re.escape(k)}}', v, expr)
expr = re.sub(rf'\re.escape(k)(?=[^a-zA-Z0-9_]|$)', v, expr)
return expr
# Example
expr = 'rate(http_requests_total{env="env",service="service"}[5m])'
vars = {"env": "production", "service": "my-service"}
runnable = substitute_vars(expr, vars)
# → rate(http_requests_total{env="production",service="my-service"}[5m])
```
---
## Find dashboards
```bash
source .env
# Search by keyword
curl -s "$GRAFANA_BASE_URL/api/search?query=<keyword>&limit=10&type=dash-db" \
-H "Cookie: grafana_session=$GRAFANA_SESSION" \
| jq '.[] | {title, uid, folderTitle}'
# Search by tag
curl -s "$GRAFANA_BASE_URL/api/search?tag=<tag-name>&limit=20" \
-H "Cookie: grafana_session=$GRAFANA_SESSION" \
| jq '.[] | {title, uid}'
```
---
## Query live metric data
```bash
source .env
# Execute a PromQL query (instant vector)
curl -s "$GRAFANA_BASE_URL/api/datasources/proxy/uid/{datasource_uid}/api/v1/query" \
-H "Cookie: grafana_session=$GRAFANA_SESSION" \
--data-urlencode "query=up" \
--data-urlencode "time=$(date +%s)" \
| jq '.data.result[] | {metric, value: .value[1]}'
# Find datasource UIDs
curl -s "$GRAFANA_BASE_URL/api/datasources" \
-H "Cookie: grafana_session=$GRAFANA_SESSION" \
| jq '.[] | {uid, name, type}'
```
---
## Notes on auth
Grafana session cookies are set after SSO login (~8h TTL). On managed machines, `playwright_sso.py` completes this automatically in a headed Chromium window without user interaction. On personal machines, it opens the Grafana login page — complete login manually once, then the session is saved.
If your Grafana uses API keys instead of SSO:
```bash
# Alternative: API key auth (if your Grafana instance supports it)
# GRAFANA_API_KEY=your-grafana-api-key
curl -s "$GRAFANA_BASE_URL/api/dashboards/uid/{uid}" \
-H "Authorization: Bearer $GRAFANA_API_KEY" \
| jq '.dashboard.title'
```
FILE:references/tool_connections/grafana/setup.md
---
name: grafana-setup
description: Set up Grafana connection. Auth is SSO browser session. Only input needed from the user is the Grafana URL.
---
# Grafana — Setup
## Auth method: SSO browser session
Grafana session cookies are captured after SSO login. No API token page needed for SSO-based instances.
**What to ask the user:** "Share your Grafana URL" (e.g. `https://grafana.acme.com`).
That is the only input needed. Set `GRAFANA_BASE_URL` in `.env`, then run the SSO script.
> **Alternative:** If your Grafana instance uses API keys instead of SSO, use `connection-api-key.md` instead (ask for the API key directly — no browser automation needed).
---
## Steps (SSO)
1. Set `GRAFANA_BASE_URL` in `.env` from the URL the user provided
2. Run the SSO script:
```bash
source .venv/bin/activate
python3 tool_connections/shared_utils/playwright_sso.py --grafana-only
```
On managed machines with enterprise SSO it completes automatically (~20–30s). On personal machines, complete the Grafana login once through the browser. `GRAFANA_SESSION` is written to `.env` automatically.
---
## Verify
```bash
source .env
curl -s "$GRAFANA_BASE_URL/api/user" \
-H "Cookie: grafana_session=$GRAFANA_SESSION" \
| jq '{login, email, name}'
# → {"login": "alice", "email": "[email protected]", "name": "Alice Smith"}
# If 401/redirect: session expired — run playwright_sso.py --grafana-only to refresh
# If connection refused: check GRAFANA_BASE_URL in .env
```
**Connection details:** `tool_connections/grafana/connection-sso.md`
---
## `.env` entries
```bash
# --- Grafana ---
# Short-lived (~8h) — refresh with: python3 tool_connections/shared_utils/playwright_sso.py --grafana-only
GRAFANA_BASE_URL=https://grafana.yourcompany.com
GRAFANA_SESSION=your-grafana-session-cookie-value
```
---
## Refresh
```bash
source .venv/bin/activate
python3 tool_connections/shared_utils/playwright_sso.py --grafana-only
```
Token TTL: ~8h.
FILE:references/tool_connections/jenkins/connection-api-token.md
---
name: jenkins
auth: api-token
description: Jenkins — open-source CI/CD automation server. Use when checking build status, reading console logs to diagnose failures, listing jobs, or triggering builds.
env_vars:
- JENKINS_USER
- JENKINS_TOKEN
- JENKINS_BASE_URL
---
# Jenkins — API token (Basic auth)
Jenkins is an open-source automation server widely used for CI/CD pipelines — building, testing, and deploying software. Every object (job, build, node) exposes a JSON REST API via `/api/json`. Common agentic use cases: check if the last build passed, get build logs to diagnose failures, trigger a new build.
API docs: https://www.jenkins.io/doc/book/using/remote-access-api/
**Verified:** Production (Jenkins 2.x with Kubernetes controller) — `/api/json` + `/job/{name}/lastBuild/api/json` + `/job/{name}/lastBuild/consoleText` — 2026-03. No VPN required (depends on your instance network policy).
---
## Credentials
```bash
# Add to .env:
# JENKINS_USER=your-username
# JENKINS_TOKEN=your-api-token
# JENKINS_BASE_URL=https://jenkins.yourcompany.com
#
# JENKINS_BASE_URL can include a folder prefix if jobs live in a subfolder:
# e.g. https://jenkins.yourcompany.com/my-team
#
# Generate API token: Jenkins → top-right user icon → Configure → API Token → Add new Token
# API tokens do not expire by default. If your admin has enabled token expiry, you'll get a 401
# after the TTL — regenerate from Configure → API Token.
```
---
## Auth
Basic auth with `JENKINS_USER:JENKINS_TOKEN`:
```bash
source .env
BASE="$JENKINS_BASE_URL"
# Usage: -u "$JENKINS_USER:$JENKINS_TOKEN"
```
---
## Verify connection
```bash
source .env
curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" \
"$JENKINS_BASE_URL/api/json?tree=jobs[name,color]" \
| jq '.jobs[:3] | .[] | {name, color}'
# → [{"name": "my-pipeline", "color": "blue"}, {"name": "deploy-staging", "color": "red"}, ...]
# color: blue = passing, red = failing, grey = not built yet
# If 401: wrong user or token. If 403: user lacks read permission.
```
---
## Verified snippets
```bash
source .env
BASE="$JENKINS_BASE_URL"
# List all jobs in BASE (with status color)
curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" \
"$BASE/api/json?tree=jobs[name,color]" \
| jq '.jobs[] | {name, color}'
# → [{"name": "my-pipeline", "color": "blue"}, {"name": "deploy-staging", "color": "red"}, ...]
# Check last build status for a job
curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" \
"$BASE/job/{job-name}/lastBuild/api/json" \
| jq '{number, result, duration}'
# → {"number": 42, "result": "SUCCESS", "duration": 108355}
# result: "SUCCESS", "FAILURE", "ABORTED", null (still building)
# Get last build parameters
curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" \
"$BASE/job/{job-name}/lastBuild/api/json" \
| jq '{result, params: [.actions[] | select(.parameters) | .parameters[] | {name, value}]}'
# → {"result": "SUCCESS", "params": [{"name": "BRANCH", "value": "main"}, {"name": "ENV", "value": "staging"}]}
# Get build console log (last N lines)
curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" \
"$BASE/job/{job-name}/lastBuild/consoleText" \
| tail -20
# → (last 20 lines of build output)
# → Finished: SUCCESS
# Get console log for a specific build number
curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" \
"$BASE/job/{job-name}/{build-number}/consoleText"
# → (full log for build #N)
# List recent builds (last 10)
curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" \
"$BASE/job/{job-name}/api/json?tree=builds[number,result,duration]{0,10}" \
| jq '.builds[] | {number, result, duration}'
# → [{"number": 42, "result": "SUCCESS", "duration": 108355}, {"number": 41, "result": "FAILURE", "duration": 45200}, ...]
# Trigger a build (no parameters)
curl -s -X POST -u "$JENKINS_USER:$JENKINS_TOKEN" \
"$BASE/job/{job-name}/build"
# → (empty body, HTTP 201 = queued successfully)
# Trigger a build with parameters
curl -s -X POST -u "$JENKINS_USER:$JENKINS_TOKEN" \
"$BASE/job/{job-name}/buildWithParameters?BRANCH=main&ENV=staging"
# → (empty body, HTTP 201 = queued)
# Parameter names must match the job's defined parameters exactly.
```
---
## Nested jobs (folder structure)
Jenkins often nests jobs under folders. Chain `/job/` for each level:
```bash
# job inside a folder: BASE/job/{folder}/job/{job-name}/
curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" \
"$BASE/job/my-team/job/my-pipeline/lastBuild/api/json" \
| jq '{number, result}'
# → {"number": 9, "result": "SUCCESS"}
```
---
## Python helper
```python
import json, urllib.request, ssl, base64
from pathlib import Path
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
BASE = env["JENKINS_BASE_URL"]
creds = base64.b64encode(f"{env['JENKINS_USER']}:{env['JENKINS_TOKEN']}".encode()).decode()
HEADERS = {"Authorization": f"Basic {creds}"}
def j_get(path):
req = urllib.request.Request(BASE + path, headers=HEADERS)
return json.loads(urllib.request.urlopen(req, context=ctx, timeout=15).read())
# List jobs
jobs = j_get("/api/json?tree=jobs[name,color]")["jobs"]
for j in jobs[:5]:
print(j["name"], j.get("color", "n/a"))
# Last build for a specific job
build = j_get("/job/my-pipeline/lastBuild/api/json")
print(build["number"], build["result"])
```
---
## Notes
- **CSRF crumbs:** Some Jenkins instances require a crumb header for POST requests. If `buildWithParameters` returns 403:
```bash
CRUMB=$(curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" "$JENKINS_BASE_URL/crumbIssuer/api/json" | jq -r '"\(.crumbRequestField): \(.crumb)"')
curl -s -X POST -u "$JENKINS_USER:$JENKINS_TOKEN" -H "$CRUMB" "$JENKINS_BASE_URL/job/{job}/build"
```
- **color field:** `blue` = passing, `red` = failing, `yellow` = unstable, `grey`/`notbuilt` = no builds, `disabled` = disabled. Append `_anime` for in-progress (e.g. `blue_anime`).
- **Build shortcuts:** `lastBuild`, `lastSuccessfulBuild`, `lastFailedBuild`, `lastStableBuild` — or use a build number directly (`/job/{name}/42/`).
- **Token expiry:** API tokens do not expire by default. If your admin has enabled token expiry, a 401 after previously working auth means the token expired — regenerate from Configure → API Token.
- **Network:** Jenkins controllers are typically internal — VPN may be required.
- **Permissions:** Triggering builds and reading logs requires appropriate Jenkins role. Read-only queries usually work with any authenticated user.
FILE:references/tool_connections/jenkins/setup.md
---
name: jenkins-setup
description: Set up Jenkins connection. API token + Basic auth. Ask for Jenkins URL and credentials.
---
# Jenkins — Setup
## Step 1: Ask for a URL
Ask the user: "Share your Jenkins URL" (e.g. `https://jenkins.yourcompany.com`).
Infer `JENKINS_BASE_URL` from the URL. Note: if jobs live under a subfolder, the base URL can include the folder prefix (e.g. `https://jenkins.yourcompany.com/my-team`).
---
## Auth method: API token (Basic auth)
**What to ask the user:**
- "Your Jenkins username"
- "Your Jenkins API token" → Jenkins → top-right user icon → Configure → API Token → Add new Token
---
## Set `.env`
```bash
JENKINS_USER=your-username
JENKINS_TOKEN=your-api-token
JENKINS_BASE_URL=https://jenkins.yourcompany.com
```
---
## Verify
```bash
source .env
curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" \
"$JENKINS_BASE_URL/api/json?tree=jobs[name,color]" \
| jq '.jobs[:3] | .[] | {name, color}'
# → [{"name": "my-pipeline", "color": "blue"}, ...]
# color: blue = passing, red = failing, grey = not built yet
# If 401: wrong user or token. If 403: user lacks read permission.
```
**Connection details:** `tool_connections/jenkins/connection-api-token.md`
---
## `.env` entries
```bash
# --- Jenkins ---
JENKINS_USER=your-username
JENKINS_TOKEN=your-api-token
JENKINS_BASE_URL=https://jenkins.yourcompany.com
# Can include a folder prefix: https://jenkins.yourcompany.com/my-team
# Generate API token: Jenkins → top-right user icon → Configure → API Token → Add new Token
```
FILE:references/tool_connections/jira/connection-api-token.md
---
name: jira
auth: api-token
description: All Jira operations — fetch issues, JQL search, update fields, write descriptions/comments, REST API quirks (components, editmeta, Agile/sprint API). Use when fetching a Jira issue, listing tickets, updating fields, writing Jira comments or descriptions, or using the Jira REST API.
env_vars:
- JIRA_EMAIL
- JIRA_API_TOKEN
- JIRA_BASE_URL
---
# Jira
Env: `JIRA_EMAIL`, `JIRA_API_TOKEN`, `JIRA_BASE_URL`
```bash
# Set in .env:
# [email protected]
# JIRA_API_TOKEN=your-jira-api-token
# JIRA_BASE_URL=https://yourcompany.atlassian.net # or your self-hosted Jira URL
```
Auth: **Basic auth** — `Authorization: Basic base64(email:token)`. Atlassian Cloud personal API tokens require Basic auth, not Bearer.
**⚠ Always load credentials in Python, not bash `source .env`** — avoids silent truncation of long tokens.
```python
from pathlib import Path
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
import base64
creds = base64.b64encode(f"{env['JIRA_EMAIL']}:{env['JIRA_API_TOKEN']}".encode()).decode()
# Use: headers={"Authorization": f"Basic {creds}"}
```
**Generate token:** Jira → Profile photo → Manage account → Security → API tokens → Create
When mentioning issues, link them: `[KEY-123]($JIRA_BASE_URL/browse/KEY-123)`
## Verify connection
```python
from pathlib import Path
import urllib.request, json, ssl, base64
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
creds = base64.b64encode(f"{env['JIRA_EMAIL']}:{env['JIRA_API_TOKEN']}".encode()).decode()
req = urllib.request.Request(f"{env['JIRA_BASE_URL']}/rest/api/2/myself",
headers={"Authorization": f"Basic {creds}"})
r = json.loads(urllib.request.urlopen(req, context=ctx, timeout=10).read())
print(r.get('displayName'), r.get('emailAddress'))
# → Alice Smith [email protected]
# If you see 401: wrong email or token. If 403: token lacks permissions.
```
---
## Fetch and search
```python
from pathlib import Path
import urllib.request, json, ssl, base64, urllib.parse
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
creds = base64.b64encode(f"{env['JIRA_EMAIL']}:{env['JIRA_API_TOKEN']}".encode()).decode()
headers = {"Authorization": f"Basic {creds}", "Accept": "application/json", "Content-Type": "application/json"}
def jira_get(path):
req = urllib.request.Request(f"{env['JIRA_BASE_URL']}{path}", headers=headers)
return json.loads(urllib.request.urlopen(req, context=ctx, timeout=10).read())
def jira_post(path, data):
req = urllib.request.Request(f"{env['JIRA_BASE_URL']}{path}",
data=json.dumps(data).encode(), headers=headers, method="POST")
return json.loads(urllib.request.urlopen(req, context=ctx, timeout=10).read())
def jira_put(path, data):
req = urllib.request.Request(f"{env['JIRA_BASE_URL']}{path}",
data=json.dumps(data).encode(), headers=headers, method="PUT")
urllib.request.urlopen(req, context=ctx, timeout=10)
# Get a specific issue
issue = jira_get("/rest/api/2/issue/KEY-123")
print(issue['key'], issue['fields']['summary'], issue['fields']['status']['name'])
# Search with JQL
jql = "assignee = currentUser() AND status NOT IN (Resolved,Closed,Done) ORDER BY updated DESC"
results = jira_get(f"/rest/api/2/search?{urllib.parse.urlencode({'jql': jql, 'maxResults': 25, 'fields': 'summary,status,priority,updated'})}")
for i in results['issues']:
print(i['key'], i['fields']['summary'], i['fields']['status']['name'])
```
## Common JQL patterns
| Goal | JQL |
|------|-----|
| My open issues | `assignee = currentUser() AND status NOT IN (Resolved,Closed,Done) ORDER BY updated DESC` |
| My sprint issues | `assignee = currentUser() AND sprint in openSprints() ORDER BY rank` |
| Issues updated today | `assignee = currentUser() AND updated >= startOfDay()` |
| Issues in project | `project = MYPROJECT AND status = "In Progress"` |
| By epic | `"Epic Link" = KEY-123 AND status != Closed` (quotes required around "Epic Link") |
---
## Update fields
```python
# Update a field (e.g. summary) — uses jira_put() from above
jira_put("/rest/api/2/issue/KEY-123", {"fields": {"summary": "New summary"}})
# Update components — must use IDs, not names
jira_put("/rest/api/2/issue/KEY-123", {"fields": {"components": [{"id": "<COMPONENT_ID>"}]}})
# Add a comment
jira_post("/rest/api/2/issue/KEY-123/comment", {"body": "Comment text here."})
# Create an issue
new_issue = jira_post("/rest/api/2/issue", {
"fields": {
"project": {"key": "MYPROJECT"},
"summary": "Issue summary",
"description": "Issue description.",
"issuetype": {"name": "Task"}
}
})
print(f"Created: {new_issue['key']} — {env['JIRA_BASE_URL']}/browse/{new_issue['key']}")
```
---
## REST API quirks
**Components:** Use IDs, not names — `{"id": "123456"}` not `{"name": "Component Name"}`. Get component IDs from the project or via editmeta.
**Epic Link in JQL:** Requires quotes — `"Epic Link" = KEY-123`, not `Epic Link = KEY-123`.
**Check editable fields before updating:**
```python
editmeta = jira_get("/rest/api/2/issue/KEY-123/editmeta")
print(json.dumps(editmeta, indent=2))
```
**Sprint field:** Cannot be set via the standard REST API. Use the Agile API instead:
```python
# List boards for a project
boards = jira_get("/rest/agile/1.0/board?projectKeyOrId=MYPROJECT")
for b in boards['values']:
print(b['id'], b['name'])
# Move issue to sprint
jira_post(f"/rest/agile/1.0/sprint/<sprintId>/issue", {"issues": ["KEY-123"]})
```
---
## Formatting (wiki markup)
Jira uses **wiki markup**, not markdown. Use this when writing descriptions or comments.
| Element | Markdown (don't use) | Jira wiki markup (use this) |
|---------|----------------------|-----------------------------|
| Heading 1 | `# Title` | `h1. Title` |
| Heading 2 | `## Title` | `h2. Title` |
| Bold | `**text**` | `*text*` |
| Italic | `*text*` | `_text_` |
| Bullet list | `- item` | `* item` |
| Nested bullet | ` - sub` | `** sub` |
| Numbered list | `1. item` | `# item` |
| Code block | ` ```json ` | `{code:json}...{code}` |
| Inline code | `` `code` `` | `{{code}}` |
| Link | `[text](url)` | `[text\|url]` |
| Horizontal rule | `---` | `----` |
Example:
```
h2. Section Title
* First bullet
** Nested bullet
*Bold text* and _italic text_
{code:python}
def example():
pass
{code}
File path: {{src/file.py}}
```
Tone: professional, no emojis.
FILE:references/tool_connections/jira/setup.md
---
name: jira-setup
description: Set up Jira connection. Supports Cloud (API token + Basic auth) and Server/Data Center (SSO session). Ask for any Jira ticket URL to infer base URL and variant.
---
# Jira — Setup
## Step 1: Ask for a URL to identify the variant
Ask the user: "Share any Jira ticket URL."
Infer variant from the URL:
- `yourcompany.atlassian.net` → **Jira Cloud** → use `connection-api-token.md`
- `jira.yourcompany.com` (self-hosted) → **Jira Server / Data Center** → use `connection-sso.md` *(coming soon)*
Infer `JIRA_BASE_URL` from the URL (e.g. `https://acme.atlassian.net/browse/ENG-123` → `https://acme.atlassian.net`).
---
## Jira Cloud — API token (most common)
**What to ask the user:**
- "Paste your Jira API token" → Jira → Profile photo → Manage account → Security → API tokens → Create
- "Your Atlassian account email"
**Set `.env`:**
```bash
[email protected]
JIRA_API_TOKEN=your-jira-api-token
JIRA_BASE_URL=https://yourcompany.atlassian.net # inferred from URL they shared
```
**Verify:**
```python
from pathlib import Path
import urllib.request, json, ssl, base64
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
creds = base64.b64encode(f"{env['JIRA_EMAIL']}:{env['JIRA_API_TOKEN']}".encode()).decode()
req = urllib.request.Request(f"{env['JIRA_BASE_URL']}/rest/api/2/myself",
headers={"Authorization": f"Basic {creds}"})
r = json.loads(urllib.request.urlopen(req, context=ctx, timeout=10).read())
print(r.get('displayName'), r.get('emailAddress'))
# → Alice Smith [email protected]
# If 401: wrong email or token. If 403: token lacks permissions.
```
**Connection details:** `tool_connections/jira/connection-api-token.md`
---
## `.env` entries
```bash
# --- Jira ---
[email protected]
JIRA_API_TOKEN=your-jira-api-token
JIRA_BASE_URL=https://yourcompany.atlassian.net
# Auth is Basic base64(email:token) — not Bearer. Always load via Python, not bash source.
# Generate token at: Jira → Profile photo → Manage account → Security → API tokens → Create
```
FILE:references/tool_connections/microsoft-teams/connection-personal-sso.md
---
name: microsoft-teams-personal
auth: sso-session
description: Microsoft Teams (personal) — read and send messages in personal Teams chats via private SSO session. Use when reading chat history, sending messages, or listing chats for a personal Microsoft account (teams.live.com).
env_vars:
- TEAMS_SKYPETOKEN
- TEAMS_SESSION_ID
- TEAMS_BASE_URL
---
# Microsoft Teams (personal)
Personal/consumer Microsoft Teams, accessed at `https://teams.live.com/v2/`. Uses a private API at `teams.live.com/api/` and `msgapi.teams.live.com/`, authenticated via a Skype-derived session token (`x-skypetoken`).
**⚠ Private API:** These endpoints are undocumented and not officially supported by Microsoft for third-party use. They may change without notice. Enterprise Teams users (work/school accounts at `teams.microsoft.com`) should use Microsoft Graph API instead.
Env: `TEAMS_SKYPETOKEN`, `TEAMS_SESSION_ID` (~24h — refresh with `assets/playwright_sso.py --teams-only`)
API docs: none (private API — community-discovered)
**Verified:** Production (teams.live.com + msgapi.teams.live.com) — list chats, read messages, send message — 2026-03. No VPN required. Personal Microsoft account. Token capture via Playwright network header interception.
---
## Auth setup
Teams (personal) has no API token page. Run the SSO script — it opens a Chromium window, you log in with your Microsoft personal account once, and tokens are written to `.env` automatically:
```bash
source .venv/bin/activate
python3 tool_connections/shared_utils/playwright_sso.py --teams-only
```
The script intercepts `x-skypetoken` from outgoing network request headers as the Teams app loads. On managed Azure AD machines this may auto-complete; on personal machines complete the Microsoft login once through the browser (~30–45s after login).
---
## Verify connection
```python
from pathlib import Path
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
import urllib.request, json, ssl
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(
f"{env['TEAMS_BASE_URL']}/api/csa/api/v1/teams/users/me",
headers={"x-skypetoken": env["TEAMS_SKYPETOKEN"],
"x-ms-session-id": env["TEAMS_SESSION_ID"]})
r = json.loads(urllib.request.urlopen(req, context=ctx, timeout=10).read())
print("ok" if "metadata" in r else r)
# → ok
# If 401: token expired — run playwright_sso.py --teams-only to refresh
```
---
## Quick-reference snippets (verified)
```python
from pathlib import Path
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
import urllib.request, json, ssl, time
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
skypetoken = env["TEAMS_SKYPETOKEN"]
session_id = env["TEAMS_SESSION_ID"]
BASE = env["TEAMS_BASE_URL"]
def teams_get(url, extra_headers=None):
headers = {"x-skypetoken": skypetoken, "x-ms-session-id": session_id}
if extra_headers:
headers.update(extra_headers)
req = urllib.request.Request(url, headers=headers)
return json.loads(urllib.request.urlopen(req, context=ctx, timeout=10).read())
# List all chats — returns chat IDs and member MRIs
r = teams_get(f"{BASE}/api/csa/api/v1/teams/users/me")
for c in r.get("chats", []):
members = [m["mri"] for m in c.get("members", [])]
print(c["id"], members)
# → 19:[email protected] ['8:other_user', '8:live:.cid.xxxxxxxxxxxxxxxx']
# Own MRI is the live:.cid.* entry. Chat IDs are needed for reading/sending.
# Returns empty list if account has no active chats.
# Read recent messages from a chat
CHAT_ID = "<chat-id-from-above>" # e.g. "19:[email protected]"
req = urllib.request.Request(
f"https://msgapi.teams.live.com/v1/users/ME/conversations/{CHAT_ID}/messages"
"?startTime=0&pageSize=20&view=msnp24Equivalent|supportsMessageProperties",
headers={"authentication": f"skypetoken={skypetoken}", "x-ms-session-id": session_id})
with urllib.request.urlopen(req, context=ctx, timeout=10) as resp:
msgs = json.loads(resp.read()).get("messages", [])
for m in msgs[-5:]:
print(f"[{m.get('originalarrivaltime','?')}] {m.get('imdisplayname','?')}: {m.get('content','')[:80]}")
# → [2026-03-17T16:28:34.1400000Z] Agent: Hello from 10xProductivity agent — connection test 2026-03-17
# → [2026-03-17T16:26:42.5060000Z] Alice: <p>hi</p>
# Note: content field contains HTML — strip tags for plain text.
# Send a message to a chat
payload = {
"content": "Hello from 10xProductivity agent",
"messagetype": "RichText/Html",
"contenttype": "text",
"amsreferences": [],
"clientmessageid": str(int(time.time() * 1000)),
"imdisplayname": "Agent",
"properties": {"importance": "", "subject": ""},
}
req = urllib.request.Request(
f"https://msgapi.teams.live.com/v1/users/ME/conversations/{CHAT_ID}/messages",
data=json.dumps(payload).encode(),
headers={"authentication": f"skypetoken={skypetoken}",
"x-ms-session-id": session_id,
"content-type": "application/json;charset=UTF-8"},
method="POST")
with urllib.request.urlopen(req, context=ctx, timeout=10) as resp:
r = json.loads(resp.read())
print(f"HTTP {resp.status}", r)
# → HTTP 201 {"OriginalArrivalTime": 1773764914140} # milliseconds epoch timestamp
```
---
## Full API surface (verified working)
| Method | Endpoint | Auth headers | Description |
|--------|----------|-------------|-------------|
| GET | `{BASE}/api/csa/api/v1/teams/users/me` | `x-skypetoken`, `x-ms-session-id` | List all chats + member MRIs; verify token via `metadata.syncToken` |
| GET | `https://msgapi.teams.live.com/v1/users/ME/conversations/{chatId}/messages?startTime=0&pageSize=N&view=msnp24Equivalent\|supportsMessageProperties` | `authentication: skypetoken=...`, `x-ms-session-id` | Read messages in a chat |
| POST | `https://msgapi.teams.live.com/v1/users/ME/conversations/{chatId}/messages` | `authentication: skypetoken=...`, `x-ms-session-id` | Send a message — returns `{"OriginalArrivalTime": ms}` |
---
## Notes
- **No search API.** All search endpoint patterns (`/api/mt/beta/search`, `/api/csa/api/v1/search`, POST variants, substrate.office.com, Bearer token variants) returned 401 or 404 with a valid skypetoken. Search requires an Azure AD OAuth2 Bearer token, not accessible via this SSO flow. Workaround: fetch full conversation history and filter client-side.
- **`/api/mt/` endpoints return 401.** The `/api/mt/beta/` namespace requires an Azure AD Bearer token, not skypetoken.
- **Personal accounts only.** For enterprise Teams (work/school), use Microsoft Graph API (`graph.microsoft.com`).
- **MRI format.** Own user identifier: `8:live:.cid.XXXXXXXXXXXXXXXX`. Chat IDs: `19:[email protected]`.
- **HTML content.** The `content` field in messages contains HTML (`RichText/Html` type). Strip tags for plain text.
FILE:references/tool_connections/microsoft-teams/setup.md
---
name: microsoft-teams-setup
description: Set up Microsoft Teams connection. Supports personal accounts (teams.live.com) via SSO session. Enterprise Teams (teams.microsoft.com) not yet supported. Ask for any Teams link to detect the variant.
---
# Microsoft Teams — Setup
## Step 1: Ask for a URL to identify the variant
Ask the user: "Share any Teams link or message URL."
Infer variant from the URL:
- `teams.live.com` → **Teams (personal)** — use the flow below
- `teams.microsoft.com` → **Enterprise Teams** — not yet supported (contribution welcome via `add-new-tool.md`)
---
## Teams (personal) — SSO browser session
No API token page exists. Auth uses a Skype-derived session token captured from network headers.
**What to ask the user:** Nothing beyond the URL (used to confirm variant).
Run the SSO script:
```bash
source .venv/bin/activate
python3 tool_connections/shared_utils/playwright_sso.py --teams-only
```
The script opens a Chromium window. On managed Azure AD machines it may auto-complete. On personal machines, log in with your Microsoft personal account (~30–45s). `TEAMS_SKYPETOKEN` and `TEAMS_SESSION_ID` are written to `.env` automatically.
---
## Verify
```python
from pathlib import Path
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
import urllib.request, json, ssl
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(
f"{env['TEAMS_BASE_URL']}/api/csa/api/v1/teams/users/me",
headers={"x-skypetoken": env["TEAMS_SKYPETOKEN"],
"x-ms-session-id": env["TEAMS_SESSION_ID"]})
r = json.loads(urllib.request.urlopen(req, context=ctx, timeout=10).read())
print("ok" if "metadata" in r else r)
# → ok
# If 401: token expired — run playwright_sso.py --teams-only to refresh
```
**Connection details:** `tool_connections/microsoft-teams/connection-personal-sso.md`
---
## `.env` entries
```bash
# --- Microsoft Teams (personal) ---
# Short-lived (~24h) — refresh with: python3 tool_connections/shared_utils/playwright_sso.py --teams-only
TEAMS_SKYPETOKEN=your-skypetoken-here
TEAMS_SESSION_ID=your-session-id-uuid-here
TEAMS_BASE_URL=https://teams.live.com
```
---
## Refresh
```bash
source .venv/bin/activate
python3 tool_connections/shared_utils/playwright_sso.py --teams-only
```
Token TTL: ~24h.
FILE:references/tool_connections/outlook/connection-m365.md
---
name: outlook
auth: sso-session
description: Outlook / Microsoft 365 work account — email, calendar, contacts, people suggestions via Outlook REST API v2.0 and Microsoft Graph. Use when reading mail, checking calendar events, looking up contacts, or finding colleague info for a work Microsoft 365 account.
env_vars:
- GRAPH_ACCESS_TOKEN
- OWA_ACCESS_TOKEN
---
# Outlook / Microsoft 365 (Work Account)
Work Outlook access for email, calendar, and contacts via two captured Bearer tokens:
- **Graph token** (`GRAPH_ACCESS_TOKEN`): `graph.microsoft.com` — user profile, people suggestions
- **OWA token** (`OWA_ACCESS_TOKEN`): `outlook.office.com/api/v2.0` — mail folders, messages, calendar, contacts
Both are captured in a single Playwright SSO pass (~30–40s). Token lifetime: ~1h.
> **⚠ Tenant note:** `graph.microsoft.com/v1.0/me/messages` and `/me/mailFolders` return 403 on some tenants (admin policy). Use the OWA REST API (`outlook.office.com/api/v2.0`) for all mail/calendar operations — it returns 200 for the same data.
Env: `GRAPH_ACCESS_TOKEN`, `OWA_ACCESS_TOKEN` (~1h — refresh with `assets/playwright_sso.py --outlook-only`)
API docs: [Outlook REST API v2.0](https://learn.microsoft.com/en-us/previous-versions/office/office-365-api/api/version-2.0/mail-rest-operations)
**Verified:** Production (outlook.office.com + graph.microsoft.com) — MailFolders, Messages, CalendarView, Contacts, /me, /me/people — 2026-03-17. No VPN required. Work Microsoft 365 account (Azure AD SSO). macOS enterprise SSO auto-completes in Playwright Chromium.
---
## Auth setup
Tokens are captured by opening `outlook.office.com` in a headed Playwright browser. On a Workday-managed Mac (or any machine with the Microsoft Enterprise SSO extension), Azure AD login auto-completes in ~30s. On unmanaged machines, complete the login once manually through the browser.
```bash
source .venv/bin/activate
python3 tool_connections/shared_utils/playwright_sso.py --outlook-only
# Opens browser → Azure AD SSO (auto-completes on managed Mac, ~30s)
# Writes GRAPH_ACCESS_TOKEN + OWA_ACCESS_TOKEN to .env
```
The script intercepts two tokens from network requests as the Outlook app loads:
- **Graph token**: from the first `graph.microsoft.com` request (user photo)
- **OWA token**: from `outlook.office.com/owa/startupdata.ashx` (app startup data)
---
## Verify connection
```python
from pathlib import Path
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
import urllib.request, json, ssl
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
# Verify Graph token
req = urllib.request.Request("https://graph.microsoft.com/v1.0/me",
headers={"Authorization": f"Bearer {env['GRAPH_ACCESS_TOKEN']}"})
r = json.loads(urllib.request.urlopen(req, context=ctx, timeout=10).read())
print(r["displayName"], r["mail"])
# → Alice Smith [email protected]
# Verify OWA token
req = urllib.request.Request("https://outlook.office.com/api/v2.0/me/MailFolders/Inbox?$select=DisplayName,UnreadItemCount",
headers={"Authorization": f"Bearer {env['OWA_ACCESS_TOKEN']}"})
r = json.loads(urllib.request.urlopen(req, context=ctx, timeout=10).read())
print(r["DisplayName"], r["UnreadItemCount"])
# → Inbox 42
# If 401: token expired — run playwright_sso.py --outlook-only to refresh
```
---
## Quick-reference snippets (verified)
```python
from pathlib import Path
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
import urllib.request, json, ssl
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
graph_tok = env["GRAPH_ACCESS_TOKEN"]
owa_tok = env["OWA_ACCESS_TOKEN"]
OWA_BASE = "https://outlook.office.com/api/v2.0"
def owa_get(path):
req = urllib.request.Request(f"{OWA_BASE}{path}",
headers={"Authorization": f"Bearer {owa_tok}", "Accept": "application/json"})
with urllib.request.urlopen(req, context=ctx, timeout=10) as r:
return json.loads(r.read())
def graph_get(path):
req = urllib.request.Request(f"https://graph.microsoft.com/v1.0{path}",
headers={"Authorization": f"Bearer {graph_tok}", "Accept": "application/json"})
with urllib.request.urlopen(req, context=ctx, timeout=10) as r:
return json.loads(r.read())
# --- User profile (Graph) ---
me = graph_get("/me")
print(me["displayName"], me["mail"], me["jobTitle"])
# → Alice Smith [email protected] Senior Engineer
# --- Mail folders (OWA) ---
folders = owa_get("/me/MailFolders?$top=10&$select=DisplayName,UnreadItemCount,TotalItemCount")
for f in folders["value"]:
print(f["DisplayName"], f["UnreadItemCount"], "/", f["TotalItemCount"])
# → Inbox 5 / 1240
# → Sent Items 0 / 832
# → Archive 0 / 15
# --- Inbox folder details ---
inbox = owa_get("/me/MailFolders/Inbox?$select=Id,DisplayName,UnreadItemCount")
print(inbox["DisplayName"], inbox["UnreadItemCount"])
# → Inbox 5
# --- Recent messages (OWA) ---
msgs = owa_get("/me/MailFolders/Inbox/Messages?$top=5&$orderby=ReceivedDateTime desc"
"&$select=Subject,ReceivedDateTime,From,IsRead,BodyPreview")
for m in msgs["value"]:
sender = m["From"]["EmailAddress"]["Name"]
print(f"[{'READ' if m['IsRead'] else 'UNREAD'}] {m['Subject'][:60]} from {sender}")
# → [UNREAD] Q1 planning update from Alice Smith
# → [READ] Weekly standup notes from Bob Jones
# --- Calendar events (OWA) — today's meetings ---
import datetime
today = datetime.date.today().isoformat()
tomorrow = (datetime.date.today() + datetime.timedelta(days=1)).isoformat()
events = owa_get(f"/me/CalendarView?startDateTime={today}T00:00:00Z&endDateTime={tomorrow}T00:00:00Z"
"&$top=10&$select=Subject,Start,End,Organizer,Attendees")
for e in events["value"]:
start = e["Start"]["DateTime"][:16].replace("T", " ")
print(f"{start} {e['Subject']}")
# → 2026-03-17 14:00 Weekly Sync
# → 2026-03-17 18:30 1:1 with Manager
# --- People suggestions (Graph — who you work with most) ---
people = graph_get("/me/people?$top=5&$select=displayName,emailAddresses,jobTitle")
for p in people["value"]:
emails = [e["address"] for e in p.get("emailAddresses", [])]
print(p["displayName"], emails[0] if emails else "")
# → Bob Jones [email protected]
# → Alice Smith [email protected]
# --- Search messages (OWA) ---
results = owa_get("/me/Messages?$search=\"project review\"&$top=5&$select=Subject,ReceivedDateTime,From")
for m in results.get("value", []):
print(m["Subject"])
# → Q4 project review notes
# --- Specific folder messages ---
# Use folder DisplayName as shortcut: Inbox, SentItems, Drafts, DeletedItems, Archive
msgs = owa_get("/me/MailFolders/SentItems/Messages?$top=5&$orderby=LastModifiedDateTime desc"
"&$select=Subject,ReceivedDateTime,ToRecipients")
for m in msgs["value"]:
to = [r["EmailAddress"]["Name"] for r in m.get("ToRecipients", [])]
print(f"{m['Subject'][:50]} → {', '.join(to[:2])}")
```
---
## Full API surface (verified working)
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | `graph.microsoft.com/v1.0/me` | Graph | Current user profile |
| GET | `graph.microsoft.com/v1.0/me/people?$top=N` | Graph | Colleagues you interact with most |
| GET | `outlook.office.com/api/v2.0/me/MailFolders` | OWA | All mail folders |
| GET | `outlook.office.com/api/v2.0/me/MailFolders/Inbox` | OWA | Inbox with unread count |
| GET | `outlook.office.com/api/v2.0/me/MailFolders/{name}/Messages` | OWA | Messages in folder |
| GET | `outlook.office.com/api/v2.0/me/Messages?$search="..."` | OWA | Full-text search across all mail |
| GET | `outlook.office.com/api/v2.0/me/CalendarView?startDateTime=...&endDateTime=...` | OWA | Calendar events in date range |
| GET | `outlook.office.com/api/v2.0/me/Contacts` | OWA | Contacts |
---
## Notes
- **OWA API deprecation:** `outlook.office.com/api/v2.0` is the "Outlook REST API" (legacy) but still fully functional as of 2026-03. Microsoft Graph (`graph.microsoft.com/v1.0/me/messages`) is the preferred API but requires `Mail.Read` consent which may be blocked by tenant policy. Use OWA v2.0 as the reliable fallback.
- **Token sources:** Graph token comes from the `graph.microsoft.com/v1.0/users/{upn}/photo/$value` request fired by the Outlook app header. OWA token comes from `outlook.office.com/owa/startupdata.ashx?app=Mail`. Both fire within seconds of the app loading.
- **Folder name shortcuts:** `Inbox`, `SentItems`, `Drafts`, `DeletedItems`, `JunkEmail`, `Archive` work as well-known folder names without needing to look up folder IDs.
- **Graph mail 403:** If `graph.microsoft.com/v1.0/me/messages` returns 403, it's a tenant policy — use OWA v2.0 instead. Graph `/me` and `/me/people` are not affected.
- **No VPN required:** All endpoints accessible from any network.
FILE:references/tool_connections/outlook/connection-personal.md
---
name: outlook-com
auth: api-token
description: Outlook.com — read-only email via OWA REST API v2.0 using OUTLOOK_ACCESS_TOKEN captured by get_outlook_token.py. Use when reading inbox/mail folders, fetching message details, and searching messages for personal Microsoft accounts (outlook.live.com).
env_vars:
- OUTLOOK_ACCESS_TOKEN
---
# Outlook.com — OWA REST API (outlook.live.com)
Outlook.com (outlook.live.com/mail/) for personal Microsoft accounts.
API base: `https://outlook.office.com/api/v2.0/me/`
API docs: https://learn.microsoft.com/en-us/graph/api/resources/mail-api-overview
---
## What works
| Capability | Status | Notes |
|---|---|---|
| Token capture | **Works** | `get_outlook_token.py` — ~15s, no login prompt if session < 24h |
| Read inbox | **Works** | `GET /me/mailfolders/inbox/messages` |
| List mail folders | **Works** | `GET /me/mailfolders` |
| Read `/me` (identity) | **Works** | Returns display name, email address |
| Send email | **Does not work** | Token has insufficient scope — see below |
| Compose via browser UI | **Not attempted fully** | Playwright UI automation hit session expiry timing issues |
---
## What does not work and why
### Send email (403 Forbidden)
The Bearer token captured by `get_outlook_token.py` comes from a request Outlook's page thread makes to `outlook.live.com/imageB2/v1.0/users/.../image/` (profile images). This token is **scoped for read operations only** — it does not include `mail.send` permission.
Attempts to use it against `POST /api/v2.0/me/sendmail` return:
```json
{"error": {"code": "ErrorAccessDenied", "message": "Access is denied."}}
```
**Root cause:** Outlook Live uses different tokens for different operations. The send token is acquired via the service worker (which processes outbound compose requests internally) — invisible to Playwright's page-level CDP `Network.enable`.
**What would fix it:** Intercept the token from inside the service worker. Playwright's `Worker` object does not expose `create_cdp_session()`, so this path is not directly available. The alternative is the Microsoft Graph device code flow (requires Azure app registration).
### Device code flow (first-party client ID blocked)
Attempt: use Outlook's own first-party client ID (`9199bf20-a13f-4107-85dc-02114787ef48`) with Python `msal` device code flow — no Azure app registration needed.
Result: Microsoft blocks device code flow for first-party app IDs when called from external callers. Login attempt failed.
### Browser UI compose + send via Playwright
Attempt: Open Outlook in a Playwright browser, click New mail, fill To/Subject/Body, click Send.
Result: The approach is sound but hit two issues:
1. The saved browser profile session expires in ~24h. When expired, Playwright opens a login page and the script exits without sending.
2. Timing: the script checked `page.url` to detect login completion but the URL matches the inbox path while Outlook's service worker is still booting (shows a loading screen for 5-10s). The "New mail" button didn't exist yet when the script tried to click it.
Both issues are fixable (add `wait_for_selector("button[aria-label='New mail']")` and handle the login wait loop) but not pursued further.
---
## Auth setup (token capture — read operations only)
```bash
python3 tool_connections/outlook/get_outlook_token.py
```
A browser window opens to `outlook.live.com/mail/inbox`. If already logged in (session < ~24h) the token is captured automatically in ~15s. If not, sign in — the script captures the token after login. Result: `OUTLOOK_ACCESS_TOKEN` written to `.env`.
**Token TTL:** ~1 hour. Re-run to refresh. Session TTL: ~24 hours.
---
## Working API calls
### Read inbox
```
GET https://outlook.live.com/api/v2.0/me/mailfolders/inbox/messages
?$top=10
&$select=Subject,From,ReceivedDateTime,IsRead,BodyPreview
&$orderby=ReceivedDateTime desc
Authorization: Bearer {OUTLOOK_ACCESS_TOKEN}
```
> **Note:** Use `outlook.live.com` as the base, not `outlook.office.com`. The `$orderby` parameter does not sort correctly via the office.com endpoint for this token type — use the live.com base URL and sort client-side if needed.
### Read full email
```
GET https://outlook.live.com/api/v2.0/me/messages/{message_id}
?$select=Subject,From,ToRecipients,Body,ReceivedDateTime
Authorization: Bearer {OUTLOOK_ACCESS_TOKEN}
```
### Search
```
GET https://outlook.live.com/api/v2.0/me/messages
?$search="from:[email protected]"
&$top=10
&$select=Subject,From,ReceivedDateTime
Authorization: Bearer {OUTLOOK_ACCESS_TOKEN}
```
### List folders
```
GET https://outlook.live.com/api/v2.0/me/mailfolders
?$select=DisplayName,UnreadItemCount,TotalItemCount
Authorization: Bearer {OUTLOOK_ACCESS_TOKEN}
```
### Identity check
```
GET https://outlook.live.com/api/v2.0/me
Authorization: Bearer {OUTLOOK_ACCESS_TOKEN}
```
---
## For send capability — future work
Two viable paths, neither implemented:
**Option A — Microsoft Graph device code flow (requires Azure app registration)**
Free, ~5 min setup at portal.azure.com. Register an app, enable `Mail.Send` delegated scope, run device code flow once to get a refresh token. Refresh tokens are long-lived (90 days rolling). This is the clean, supported path.
**Option B — Playwright browser UI automation (no app registration)**
Open Outlook in a Playwright browser with the saved profile. Wait for `button[aria-label='New mail']` to appear (not just the URL), fill To/Subject/Body, click Send. The browser handles auth internally — no token extraction needed. Limitation: requires a live browser session (~24h before re-login).
---
## Notes
- **How token capture works:** Playwright CDP `Network.enable` intercepts HTTP requests on the page thread. Outlook's service worker handles internal caching but the page thread makes direct requests for profile images and config endpoints — these carry a Bearer token visible at the CDP level.
- **Service worker limitation:** `fetch()` calls made by the page's JavaScript are intercepted by `sw.js` before reaching the network. Only requests originating from the browser engine itself (image loads, preload hints) bypass the service worker and are visible to CDP.
- **No Azure app registration needed** for read operations. Required for send.
- **No VPN required.** `outlook.live.com` and `outlook.office.com` are public endpoints.
FILE:references/tool_connections/outlook/setup.md
---
name: outlook-setup
description: Set up Outlook connection. Supports M365 work accounts (SSO) and personal Outlook.com accounts (token capture). Ask for any Outlook URL to detect the variant.
---
# Outlook — Setup
## Step 1: Ask for a URL to identify the variant
Ask the user: "Share any Outlook link or email URL."
Infer variant from the URL:
- `outlook.office.com` or `office365` or `office.com` → **Outlook / Microsoft 365** (work account) → `connection-m365.md`
- `outlook.live.com` or `outlook.com` → **Outlook.com** (personal account) → `connection-personal.md`
---
## Variant A: Outlook / Microsoft 365 (work account)
Auth uses two Bearer tokens captured from network requests as Outlook loads. No API token page exists — run the SSO script:
```bash
source .venv/bin/activate
python3 tool_connections/shared_utils/playwright_sso.py --outlook-only
```
On a managed machine (Workday/Intune/MDM), Azure AD SSO auto-completes in ~30s. On unmanaged machines, complete the Microsoft 365 login once through the browser.
Two tokens are written to `.env`:
- `GRAPH_ACCESS_TOKEN` — for Microsoft Graph (`/me`, `/me/people`)
- `OWA_ACCESS_TOKEN` — for Outlook REST API v2.0 (mail, calendar, contacts)
**Verify:**
```python
from pathlib import Path
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
import urllib.request, json, ssl
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request("https://graph.microsoft.com/v1.0/me",
headers={"Authorization": f"Bearer {env['GRAPH_ACCESS_TOKEN']}"})
r = json.loads(urllib.request.urlopen(req, context=ctx, timeout=10).read())
print(r["displayName"], r["mail"])
# → Alice Smith [email protected]
# If 401: token expired — run playwright_sso.py --outlook-only to refresh
```
Token TTL: ~1h. **Connection details:** `tool_connections/outlook/connection-m365.md`
---
## Variant B: Outlook.com (personal account)
```bash
source .venv/bin/activate
python3 tool_connections/outlook/get_outlook_token.py
```
A browser window opens to `outlook.live.com/mail/inbox`. If already logged in (session < ~24h) the token is captured in ~15s. Result: `OUTLOOK_ACCESS_TOKEN` written to `.env`.
**Verify:**
```python
from pathlib import Path
import urllib.request, json, ssl
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(
"https://outlook.live.com/api/v2.0/me/mailfolders/inbox/messages?$top=1&$select=Subject",
headers={"Authorization": f"Bearer {env['OUTLOOK_ACCESS_TOKEN']}"})
r = json.loads(urllib.request.urlopen(req, context=ctx, timeout=10).read())
print(r.get("value", [{}])[0].get("Subject"))
# → Subject of most recent email
```
Token TTL: ~1h. Session TTL: ~24h. **Connection details:** `tool_connections/outlook/connection-personal.md`
---
## `.env` entries
```bash
# --- Outlook / Microsoft 365 (work account) ---
# Short-lived (~1h) — refresh with: python3 tool_connections/shared_utils/playwright_sso.py --outlook-only
GRAPH_ACCESS_TOKEN=your-graph-bearer-token-here
OWA_ACCESS_TOKEN=your-owa-bearer-token-here
# --- Outlook.com (personal account) ---
# Short-lived (~1h) — refresh with: python3 tool_connections/outlook/get_outlook_token.py
OUTLOOK_ACCESS_TOKEN=your-outlook-access-token
```
FILE:references/tool_connections/pagerduty/connection-api-token.md
---
name: pagerduty
auth: api-token
description: PagerDuty — incident management, on-call scheduling, alerting. Use when looking up active incidents, checking who is on call, querying service status, or reading escalation policies. Requires PAGERDUTY_TOKEN (personal REST API key). Read-only personal use is fine; production write integrations may require approval from your security team.
env_vars:
- PAGERDUTY_TOKEN
---
# PagerDuty
Primary incident management platform for on-call scheduling, incident response, alerting, and escalation.
Env: `PAGERDUTY_TOKEN` (personal REST API key — long-lived, does not expire)
Web UI: https://app.pagerduty.com (or your company's subdomain: `yourcompany.pagerduty.com`)
REST API docs: https://developer.pagerduty.com/api-reference
---
## Auth setup (one-time)
1. Log into PagerDuty
2. Click your avatar → **My Profile**
3. Go to **User Settings** tab → **API Access** section
4. Click **Create New API Key**, give it a name (e.g. `local-agent`), copy the key
5. Add to `.env`:
```bash
# --- PagerDuty ---
PAGERDUTY_TOKEN=your-personal-api-key-here
```
Auth header: `Authorization: Token token=$PAGERDUTY_TOKEN`
## Verify connection
```bash
source .env
curl -s "https://api.pagerduty.com/users/me" \
-H "Authorization: Token token=$PAGERDUTY_TOKEN" \
-H "Accept: application/vnd.pagerduty+json;version=2" \
| jq '{name: .user.name, email: .user.email, role: .user.role}'
# → {"name": "Alice Smith", "email": "[email protected]", "role": "limited_user"}
# If you see 401: token is wrong or expired — generate a new one in PagerDuty.
```
---
## Quick-reference snippets
```bash
source .env
BASE="https://api.pagerduty.com"
AUTH="Authorization: Token token=$PAGERDUTY_TOKEN"
# Current user
curl -s "$BASE/users/me" \
-H "$AUTH" -H "Accept: application/vnd.pagerduty+json;version=2" \
| jq '{id: .user.id, name: .user.name, email: .user.email, role: .user.role}'
# List active incidents (triggered + acknowledged)
curl -s "$BASE/incidents?statuses[]=triggered&statuses[]=acknowledged&limit=10&sort_by=created_at:desc" \
-H "$AUTH" -H "Accept: application/vnd.pagerduty+json;version=2" \
| jq '.incidents[] | {id, title, status, urgency, priority: .priority.name, service: .service.summary}'
# Who's on call right now (all schedules)
curl -s "$BASE/oncalls?limit=25" \
-H "$AUTH" -H "Accept: application/vnd.pagerduty+json;version=2" \
| jq '.oncalls[] | {user: .user.summary, schedule: .schedule.summary, escalation_policy: .escalation_policy.summary}'
# Who's on call for a specific escalation policy
curl -s "$BASE/oncalls?escalation_policy_ids[]=<POLICY_ID>" \
-H "$AUTH" -H "Accept: application/vnd.pagerduty+json;version=2" \
| jq '.oncalls[] | {level: .escalation_level, user: .user.summary}'
# Get service by ID
curl -s "$BASE/services/<SERVICE_ID>" \
-H "$AUTH" -H "Accept: application/vnd.pagerduty+json;version=2" \
| jq '{name: .service.name, status: .service.status, escalation: .service.escalation_policy.summary}'
# List services
curl -s "$BASE/services?limit=25&sort_by=name" \
-H "$AUTH" -H "Accept: application/vnd.pagerduty+json;version=2" \
| jq '.services[] | {id, name, status, team: (.teams[0].summary // "none")}'
# List schedules
curl -s "$BASE/schedules?limit=25" \
-H "$AUTH" -H "Accept: application/vnd.pagerduty+json;version=2" \
| jq '.schedules[] | {id, name, time_zone}'
# Get a schedule's current on-call (today)
TODAY=$(date -u +%Y-%m-%dT%H:%M:%SZ)
curl -s "$BASE/schedules/<SCHEDULE_ID>?since=TODAY&until=TODAY" \
-H "$AUTH" -H "Accept: application/vnd.pagerduty+json;version=2" \
| jq '.schedule.final_schedule.rendered_schedule_entries[0] | {user: .user.summary, start, end}'
# Search incidents by service
curl -s "$BASE/incidents?service_ids[]=<SERVICE_ID>&limit=5" \
-H "$AUTH" -H "Accept: application/vnd.pagerduty+json;version=2" \
| jq '.incidents[] | {id, title, status, created_at}'
# List teams
curl -s "$BASE/teams?limit=25" \
-H "$AUTH" -H "Accept: application/vnd.pagerduty+json;version=2" \
| jq '.teams[] | {id, name}'
# Get escalation policies
curl -s "$BASE/escalation_policies?limit=25" \
-H "$AUTH" -H "Accept: application/vnd.pagerduty+json;version=2" \
| jq '.escalation_policies[] | {id, name}'
```
---
## Python helper
```python
import json, subprocess, os
def pd_get(path, params=""):
"""Call PagerDuty REST API."""
token = os.environ.get("PAGERDUTY_TOKEN")
cmd = [
"curl", "-s",
f"https://api.pagerduty.com{path}{params}",
"-H", f"Authorization: Token token={token}",
"-H", "Accept: application/vnd.pagerduty+json;version=2",
]
return json.loads(subprocess.check_output(cmd))
# Active incidents
data = pd_get("/incidents", "?statuses[]=triggered&statuses[]=acknowledged&limit=10")
for inc in data.get("incidents", []):
print(f" [{inc['status']}] {inc['id']}: {inc['title']} (svc: {inc['service']['summary']})")
# On-call right now
data = pd_get("/oncalls", "?limit=25")
for oc in data.get("oncalls", []):
print(f" L{oc['escalation_level']} {oc['escalation_policy']['summary']}: {oc['user']['summary']}")
```
---
## Full REST API surface
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/users/me` | Current authenticated user |
| GET | `/services/{id}` | Service details (status, escalation, integrations) |
| GET | `/services` | List services (`?team_ids[]`, `?limit`, `?sort_by=name`) |
| GET | `/incidents` | List incidents (`?statuses[]=triggered`, `?service_ids[]`, `?limit`) |
| GET | `/incidents/{id}` | Single incident detail |
| GET | `/incidents/{id}/notes` | Incident notes / timeline |
| GET | `/oncalls` | Current on-call users (`?escalation_policy_ids[]`, `?schedule_ids[]`) |
| GET | `/schedules` | List schedules |
| GET | `/schedules/{id}` | Schedule with rendered entries (`?since=`, `?until=`) |
| GET | `/escalation_policies` | List escalation policies |
| GET | `/escalation_policies/{id}` | Single policy with rules |
| GET | `/teams` | List teams |
| GET | `/teams/{id}/members` | Team members |
| GET | `/users` | List users (`?team_ids[]`, `?query=name`) |
| GET | `/users/{id}` | User details |
| POST | `/incidents` | Create incident (check your org's policy before automating writes) |
| PUT | `/incidents/{id}` | Update incident status/priority |
| POST | `/incidents/{id}/notes` | Add note to incident |
---
## Key ID patterns
| Resource | Example ID | Where to find it |
|----------|-----------|-----------------|
| Service | `PW2S8FL` | URL: `app.pagerduty.com/service-directory/{ID}` |
| Escalation policy | `PXXXXXX` | From service details → escalation_policy |
| Schedule | `PXXXXXX` | From escalation policy → rules |
| User | `PXXXXXX` | From `GET /users/me` → `.user.id` |
| Incident | `PXXXXXX` | From `GET /incidents` → `.incidents[].id` |
FILE:references/tool_connections/pagerduty/setup.md
---
name: pagerduty-setup
description: Set up PagerDuty connection. API token auth. Base URL is always api.pagerduty.com — no URL needed from user.
---
# PagerDuty — Setup
## Auth method: Personal REST API token
PagerDuty's API base is always `https://api.pagerduty.com` — no URL needed from the user.
**What to ask the user:** "Paste your PagerDuty API key" → PagerDuty → top-right avatar → My Profile → User Settings → API Access → Create New API Key.
---
## Set `.env`
```bash
PAGERDUTY_TOKEN=your-personal-api-key-here
```
---
## Verify
```bash
source .env
curl -s "https://api.pagerduty.com/users/me" \
-H "Authorization: Token token=$PAGERDUTY_TOKEN" \
-H "Accept: application/vnd.pagerduty+json;version=2" \
| jq '{name: .user.name, email: .user.email, role: .user.role}'
# → {"name": "Alice Smith", "email": "[email protected]", "role": "limited_user"}
# If 401: token is wrong or expired — generate a new one in PagerDuty.
```
**Connection details:** `tool_connections/pagerduty/connection-api-token.md`
---
## `.env` entries
```bash
# --- PagerDuty ---
PAGERDUTY_TOKEN=your-personal-api-key
# Generate at: PagerDuty → My Profile → User Settings → API Access → Create New API Key
```
FILE:references/tool_connections/slack/connection-sso.md
---
name: slack
auth: sso-session
description: Slack — two complementary modes. (1) Slack AI: post a natural-language question to the Slackbot DM and get a synthesized AI answer in ~0.2s, drawn from all Slack content you have access to. (2) search.messages: raw full-text search with Slack syntax (in:#channel, from:user, date range). Also: read channel/thread history, post messages. No Slack app install needed — xoxc user session via SSO.
env_vars:
- SLACK_XOXC
- SLACK_D_COOKIE
---
# Slack
Access is via your own user session (`xoxc` client token) extracted after SSO — no Slack app installation or admin approval needed.
Env: `SLACK_XOXC`, `SLACK_D_COOKIE` (~8h — refresh via `assets/playwright_sso.py --slack-only`)
---
## Verify connection
```python
# Always use Python to load .env — bash truncates long xoxc tokens silently
from pathlib import Path
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
import urllib.request, json, ssl
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request("https://slack.com/api/auth.test",
headers={"Authorization": f"Bearer {env['SLACK_XOXC']}", "Cookie": f"d={env['SLACK_D_COOKIE']}"})
r = json.loads(urllib.request.urlopen(req, context=ctx, timeout=10).read())
print(r.get("ok"), r.get("user"), r.get("team"))
# → True alice your-workspace
# If ok=False: session expired — run playwright_sso.py --slack-only to refresh.
```
## Auth setup
**Minimum user input:** ask for a Slack message link (right-click any message → Copy link, e.g. `https://acme.slack.com/archives/C.../p...`). That's it.
From the link, extract the workspace URL yourself (`https://acme.slack.com/`), update `SLACK_WORKSPACE_URL` in `.env`, then run:
```bash
source .venv/bin/activate
python3 tool_connections/shared_utils/playwright_sso.py --slack-only
```
The script opens a Chromium window, completes SSO (auto on managed machines, manual login once on personal machines), and writes `SLACK_XOXC` and `SLACK_D_COOKIE` to `.env` automatically. Never ask the user to open DevTools or extract tokens manually.
```bash
# Refresh all tokens (Grafana + Slack in one pass):
python3 tool_connections/shared_utils/playwright_sso.py
```
**⚠ Load credentials in Python, not bash `source .env`** — xoxc tokens are long and bash may truncate them silently, causing `not_authed`. Always read `.env` directly in Python:
```python
from pathlib import Path
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
xoxc, d = env["SLACK_XOXC"], env["SLACK_D_COOKIE"]
```
---
## Slack AI — ask questions, get synthesized answers
**Requires: Slack Business+ or Enterprise+ plan.** Slack AI is not available on Free or Pro plans (as of January 2026). If the workspace is on a lower plan, skip this section — use `search.messages` instead for message search.
**Best for:** natural-language questions ("how do I X?", "what did we decide about Y?", "who owns Z?"). Slack AI searches all channels you have access to and synthesizes a cited answer.
**How it works:** post your question to your Slackbot DM channel. Slack AI responds in the *thread* with `subtype='ai'` in ~0.2s. Poll `conversations.replies` on the thread ts.
**Key gotcha:** response arrives in ~0.2s — start polling immediately (1s sleep), not with a long delay.
```python
import json, ssl, time, urllib.request, urllib.parse
from pathlib import Path
# Load credentials
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
xoxc, d = env["SLACK_XOXC"], env["SLACK_D_COOKIE"]
ssl_ctx = ssl.create_default_context()
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
def api(method, endpoint, data=None, params=None):
url = f"https://slack.com/api/{endpoint}"
if params:
url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url,
data=json.dumps(data).encode() if data else None,
headers={"Authorization": f"Bearer {xoxc}", "Cookie": f"d={d}",
"Content-Type": "application/json; charset=utf-8"},
method=method)
with urllib.request.urlopen(req, context=ssl_ctx, timeout=15) as resp:
return json.loads(resp.read())
def get_slackbot_dm() -> str:
"""Find your Slackbot DM channel ID.
⚠ Do NOT use search.messages(from:slackbot) — it returns public channel IDs (C...)
instead of the actual DM (D...). Use conversations.open with USLACKBOT instead.
USLACKBOT is Slackbot's fixed user ID across all workspaces.
"""
r = api("POST", "conversations.open", {"users": "USLACKBOT"})
if r.get("ok"):
return r["channel"]["id"]
raise RuntimeError(f"Could not open Slackbot DM: {r.get('error')}")
def extract_element(item):
"""Render one rich_text element to plain text."""
t = item.get("type", "")
if t == "text": return item.get("text", "")
if t == "link": return item.get("text") or item.get("url", "")
if t == "channel": return f"#{item.get('channel_id', '?')}"
if t == "user": return f"@{item.get('user_id', '?')}"
if t == "emoji": return f":{item.get('name', '')}:"
return ""
def extract_ai_answer(msg):
"""Extract readable text from a Slack AI message."""
parts = []
for block in msg.get("blocks", []):
if block.get("type") == "timeline":
continue # skip Slack AI's internal search traces
if block.get("type") == "rich_text":
for el in block.get("elements", []):
el_type = el.get("type", "")
items = el.get("elements", [])
if el_type == "rich_text_list":
for li in items:
parts.append(" • " + "".join(extract_element(i) for i in li.get("elements", [])))
elif el_type == "rich_text_preformatted":
parts.append("```" + "".join(extract_element(i) for i in items) + "```")
else:
parts.append("".join(extract_element(i) for i in items))
elif block.get("type") == "section" and block.get("text"):
parts.append(block["text"].get("text", ""))
return "\n".join(p for p in parts if p.strip())
def ask_slack_ai(question: str, slackbot_dm: str, thread_ts: str = None) -> tuple[str, str]:
"""
Post a question to Slack AI and return (thread_ts, answer).
Pass thread_ts to continue a conversation in the same thread.
"""
payload = {"channel": slackbot_dm, "text": question}
if thread_ts:
payload["thread_ts"] = thread_ts
r = api("POST", "chat.postMessage", payload)
if not r.get("ok"):
raise RuntimeError(f"Post failed: {r.get('error')}")
msg_ts = r["ts"]
root_ts = thread_ts or msg_ts
for _ in range(60):
time.sleep(1)
r = api("GET", "conversations.replies",
params={"channel": slackbot_dm, "ts": root_ts, "limit": "20"})
ai_replies = [m for m in r.get("messages", [])
if float(m.get("ts", "0")) > float(msg_ts)
and m.get("subtype") == "ai"]
if ai_replies:
answer = extract_ai_answer(ai_replies[-1])
if answer and "Thinking" not in answer:
return root_ts, answer
return root_ts, "(no response after 60s)"
# Usage
slackbot_dm = get_slackbot_dm() # or hardcode your DM channel ID
thread_ts, answer = ask_slack_ai("how do we handle on-call escalations?", slackbot_dm)
print(answer)
# Multi-turn conversation — pass thread_ts to maintain context
thread_ts, a1 = ask_slack_ai("What is our incident response process?", slackbot_dm)
_, a2 = ask_slack_ai("Who are the main contacts?", slackbot_dm, thread_ts=thread_ts)
```
---
## search.messages — raw full-text search
**Best for:** finding specific messages, people, decisions, or incidents. Supports full Slack search syntax.
```python
def search_slack(query: str, count: int = 10) -> list[dict]:
"""Search Slack messages. Returns list of {channel, user, text, permalink}."""
r = api("GET", "search.messages",
params={"query": query, "count": str(count), "sort": "score"})
if not r.get("ok"):
raise RuntimeError(f"search failed: {r.get('error')}")
return [
{"channel": m.get("channel", {}).get("name", "?"),
"user": m.get("username", "?"),
"text": m.get("text", ""),
"ts": m.get("ts", ""),
"permalink": m.get("permalink", "")}
for m in r.get("messages", {}).get("matches", [])
]
# Slack search syntax examples:
# search_slack("deployment failed") — keyword search
# search_slack("in:#engineering deployment") — specific channel
# search_slack("from:alice incident") — by author
# search_slack("outage after:2026-01-01") — date filter
# search_slack("api timeout", count=20) — more results
```
---
## Read a thread from a Slack URL
```python
def fetch_thread(slack_url: str) -> list[dict]:
"""
Fetch all messages in a Slack thread from its URL.
Supports: slack.com/archives/CXXXXXXX/pNNNNNNNNNNNNNNN[?thread_ts=...]
"""
import re
m = re.search(r"/archives/([A-Z0-9]+)/p(\d+)", slack_url)
channel = m.group(1)
raw_ts = m.group(2)
ts = raw_ts[:10] + "." + raw_ts[10:] # p1773406713930289 → 1773406713.930289
tts_m = re.search(r"thread_ts=([\d.]+)", slack_url)
thread_ts = tts_m.group(1) if tts_m else ts
r = api("GET", "conversations.replies",
params={"channel": channel, "ts": thread_ts, "limit": "50"})
return r.get("messages", [])
# Usage:
msgs = fetch_thread("https://yourcompany.slack.com/archives/C08E6GQMLP6/p1773406713930289")
for msg in msgs:
print(f"{msg.get('user','?')}: {msg.get('text','')[:120]}")
```
---
## API surface
| Endpoint | What it does | Notes |
|----------|-------------|-------|
| `auth.test` | Verify token, get user/team | — |
| `chat.postMessage` | Post a message | Requires `channel` + `text` |
| `conversations.replies` | Fetch thread / poll for Slack AI response | Requires `channel` + `ts` (thread root) |
| `conversations.history` | Read recent messages in a channel | Requires `channel` ID |
| `conversations.info` | Channel name, topic, metadata | Requires `channel` ID |
| `search.messages` | Full-text search across all accessible Slack | Full Slack syntax: `in:`, `from:`, `after:`, `before:` |
| `users.info` | Look up a user by ID | — |
---
## Discover channel IDs
```bash
# Workaround for Enterprise Grid (conversations.list may be blocked):
# Search for any message in the channel to get its ID
curl -s "https://slack.com/api/search.messages?query=in%3A%23channel-name+a&count=1" \
-H "Authorization: Bearer $SLACK_XOXC" -H "Cookie: d=$SLACK_D_COOKIE" \
| jq -r '.messages.matches[0].channel | {id, name}'
```
**URL parsing:**
- URL pattern: `.../archives/{CHANNEL_ID}/p{TS_NO_DOT}`
- `D...` = DM, `C...` = channel, `G...` = group DM
- `p1773406713930289` → ts `1773406713.930289`
FILE:references/tool_connections/slack/setup.md
---
name: slack-setup
description: Set up Slack connection. Auth is SSO browser session — no API token page exists. Only input needed from the user is any Slack message URL from their workspace.
---
# Slack — Setup
## Auth method: SSO browser session
Slack uses a short-lived client token (`xoxc`) + cookie (`d`) captured from your browser session after SSO. No API token page exists. No admin approval needed.
**What to ask the user:** "Send me any Slack message link from your workspace (right-click any message → Copy link)."
That is the only input needed. Everything else is automated.
> **Note:** Slack AI (natural-language Q&A) requires Business+ or Enterprise+ plan. On Free/Pro plans, `search.messages` still works for keyword search.
---
## Steps
1. Extract the workspace URL from the message link the user provides:
- e.g. `https://acme.slack.com/archives/C.../p...` → `https://acme.slack.com/`
2. Update `SLACK_WORKSPACE_URL` in `.env`
3. Run the SSO script:
```bash
source .venv/bin/activate
python3 tool_connections/shared_utils/playwright_sso.py --slack-only
```
The script opens a Chromium window. On managed machines with enterprise SSO it completes automatically (~20s). On personal machines, the user logs in once through the browser. Tokens are written to `.env` automatically.
---
## Verify
```python
from pathlib import Path
env = {k.strip(): v.strip() for line in Path(".env").read_text().splitlines()
if "=" in line and not line.startswith("#") for k, v in [line.split("=", 1)]}
import urllib.request, json, ssl
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request("https://slack.com/api/auth.test",
headers={"Authorization": f"Bearer {env['SLACK_XOXC']}", "Cookie": f"d={env['SLACK_D_COOKIE']}"})
r = json.loads(urllib.request.urlopen(req, context=ctx, timeout=10).read())
print(r.get("user"), r.get("team"))
# → alice your-workspace
# If ok=False: session expired — run playwright_sso.py --slack-only to refresh
```
---
## `.env` entries
```bash
# --- Slack ---
# Short-lived (~8h) — refresh with: python3 tool_connections/shared_utils/playwright_sso.py --slack-only
SLACK_WORKSPACE_URL=https://yourcompany.slack.com/
SLACK_XOXC=xoxc-your-slack-client-token
SLACK_D_COOKIE=xoxd-your-slack-d-cookie-value
```
---
## Refresh
```bash
source .venv/bin/activate
python3 tool_connections/shared_utils/playwright_sso.py --slack-only
```
Token TTL: ~8h. Re-run when `auth.test` returns `ok=False`.
FILE:scripts/openclaw_sync.py
#!/usr/bin/env python3
"""
Sync SSO tokens from ~/.openclaw/tool-connector.env into
~/.openclaw/openclaw.json under skills.entries.tool-connector.env.
OpenClaw injects those env vars automatically at the start of each agent run,
so tokens captured by playwright_sso.py are available without sourcing .env.
Usage:
python3 scripts/openclaw_sync.py # sync all tokens
python3 scripts/openclaw_sync.py --refresh-slack # refresh Slack SSO first, then sync
python3 scripts/openclaw_sync.py --refresh-outlook # refresh Outlook SSO first, then sync
python3 scripts/openclaw_sync.py --refresh-grafana # refresh Grafana SSO first, then sync
python3 scripts/openclaw_sync.py --refresh-teams # refresh Teams SSO first, then sync
python3 scripts/openclaw_sync.py --refresh-gdrive # refresh Google Drive SSO first, then sync
python3 scripts/openclaw_sync.py --refresh-all # refresh all SSO sessions, then sync
"""
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
ENV_FILE = Path.home() / ".openclaw" / "tool-connector.env"
OPENCLAW_CONFIG = Path.home() / ".openclaw" / "openclaw.json"
SSO_SCRIPT = Path(__file__).parent / "shared_utils" / "playwright_sso.py"
# All env var keys managed by playwright_sso.py
SSO_ENV_KEYS = [
"GRAFANA_SESSION",
"SLACK_XOXC",
"SLACK_D_COOKIE",
"GDRIVE_COOKIES",
"GDRIVE_SAPISID",
"TEAMS_SKYPETOKEN",
"TEAMS_SESSION_ID",
"GRAPH_ACCESS_TOKEN",
"OWA_ACCESS_TOKEN",
]
def read_env_file(path: Path) -> dict[str, str]:
"""Read key=value pairs from a .env file."""
result = {}
if not path.exists():
return result
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
result[key.strip()] = value.strip()
return result
def read_openclaw_config(path: Path) -> dict:
"""Read ~/.openclaw/openclaw.json, tolerating JSON5-style comments."""
if not path.exists():
return {}
text = path.read_text()
# Strip // line comments (simple JSON5 compat — sufficient for openclaw.json)
import re
text = re.sub(r"//[^\n]*", "", text)
try:
return json.loads(text)
except json.JSONDecodeError:
print(f"Warning: could not parse {path} as JSON — will create a fresh structure.")
return {}
def write_openclaw_config(path: Path, config: dict) -> None:
"""Write config back as JSON (pretty-printed)."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(config, indent=2) + "\n")
def sync_tokens_to_openclaw(tokens: dict[str, str]) -> None:
"""Patch skills.entries.tool-connector.env in openclaw.json with the given tokens.
This function ONLY writes under config["skills"]["entries"]["tool-connector"]["env"].
It does not read, modify, or delete any other keys in openclaw.json.
"""
if not tokens:
print("No tokens to sync.")
return
config = read_openclaw_config(OPENCLAW_CONFIG)
# Only touch skills.entries.tool-connector — nothing else in openclaw.json
config.setdefault("skills", {})
config["skills"].setdefault("entries", {})
config["skills"]["entries"].setdefault("tool-connector", {})
config["skills"]["entries"]["tool-connector"].setdefault("env", {})
env_block = config["skills"]["entries"]["tool-connector"]["env"]
updated = []
for key, value in tokens.items():
if value:
env_block[key] = value
updated.append(key)
write_openclaw_config(OPENCLAW_CONFIG, config)
print(f"Synced to {OPENCLAW_CONFIG}:")
for key in updated:
print(f" {key}: {tokens[key][:40]}...")
def run_sso_refresh(flag: str) -> None:
"""Run playwright_sso.py with the given --*-only flag."""
cmd = [sys.executable, str(SSO_SCRIPT), "--env-file", str(ENV_FILE), flag]
print(f"Running SSO refresh: {' '.join(cmd)}")
result = subprocess.run(cmd)
if result.returncode != 0:
print(f"SSO refresh failed (exit {result.returncode})")
sys.exit(result.returncode)
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--refresh-slack", action="store_true", help="Refresh Slack SSO before syncing")
parser.add_argument("--refresh-outlook", action="store_true", help="Refresh Outlook/M365 SSO before syncing")
parser.add_argument("--refresh-grafana", action="store_true", help="Refresh Grafana SSO before syncing")
parser.add_argument("--refresh-teams", action="store_true", help="Refresh Microsoft Teams SSO before syncing")
parser.add_argument("--refresh-gdrive", action="store_true", help="Refresh Google Drive SSO before syncing")
parser.add_argument("--refresh-all", action="store_true", help="Refresh all SSO sessions before syncing")
args = parser.parse_args()
# Run requested SSO refreshes first
if args.refresh_all:
run_sso_refresh("--slack-only")
run_sso_refresh("--outlook-only")
run_sso_refresh("--grafana-only")
run_sso_refresh("--teams-only")
run_sso_refresh("--gdrive-only")
else:
if args.refresh_slack:
run_sso_refresh("--slack-only")
if args.refresh_outlook:
run_sso_refresh("--outlook-only")
if args.refresh_grafana:
run_sso_refresh("--grafana-only")
if args.refresh_teams:
run_sso_refresh("--teams-only")
if args.refresh_gdrive:
run_sso_refresh("--gdrive-only")
# Read tokens from ~/.openclaw/tool-connector.env
env_vars = read_env_file(ENV_FILE)
tokens = {k: env_vars[k] for k in SSO_ENV_KEYS if k in env_vars and env_vars[k]}
if not tokens:
print(f"No SSO tokens found in {ENV_FILE}.")
print("Run playwright_sso.py first, or use --refresh-* flags.")
sys.exit(1)
sync_tokens_to_openclaw(tokens)
print("\nDone. Start a new OpenClaw session to pick up the updated tokens.")
if __name__ == "__main__":
main()
FILE:scripts/shared_utils/browser.py
#!/usr/bin/env python3
"""
Shared Playwright browser utilities for tool SSO scripts.
Each tool's sso.py imports from here rather than duplicating boilerplate.
Requirements:
pip install playwright && playwright install chromium
"""
import os
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
try:
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
except ImportError:
print("Installing playwright...")
os.system(f"{sys.executable} -m pip install playwright -q")
os.system(f"{sys.executable} -m playwright install chromium -q")
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
# Re-export for tool sso.py files that need it
__all__ = [
"sync_playwright", "PlaywrightTimeout",
"load_env_var", "load_env_file", "update_env_file",
"http_get", "http_get_no_redirect",
"DEFAULT_ENV_FILE",
]
DEFAULT_ENV_FILE = Path(__file__).parents[2] / ".env"
def load_env_var(key: str, default: str = "") -> str:
"""Load a variable from .env file or environment, falling back to default."""
env_file = DEFAULT_ENV_FILE
if env_file.exists():
for line in env_file.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip()
return os.environ.get(key, default)
def load_env_file(env_path: Path) -> dict:
"""Read all key=value pairs from a .env file."""
result = {}
if not env_path.exists():
return result
for line in env_path.read_text().splitlines():
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
result[k.strip()] = v.strip()
return result
def update_env_file(env_path: Path, tokens: dict) -> None:
"""Write / update token values in a .env file."""
if not env_path.exists():
env_path.write_text("")
content = env_path.read_text()
def _upsert(text: str, key: str, value: str, section_hint: str = "") -> str:
pattern = rf"^({re.escape(key)}=).*$"
new_line = f"{key}={value}"
if re.search(pattern, text, flags=re.MULTILINE):
return re.sub(pattern, new_line, text, flags=re.MULTILINE)
if section_hint and section_hint in text:
return re.sub(
rf"({re.escape(section_hint)}[^\n]*\n)",
r"\1" + new_line + "\n",
text,
)
return text + f"\n{new_line}\n"
for key, value in tokens.items():
if value:
# Map token keys to env var names and section hints
env_key = key.upper()
section_hint = _section_hint(env_key)
content = _upsert(content, env_key, value, section_hint)
env_path.write_text(content)
print(f" Updated {env_path}")
def _section_hint(env_key: str) -> str:
"""Return the .env section comment that precedes the given env var."""
hints = {
"GRAFANA_SESSION": "# --- Grafana",
"SLACK_XOXC": "# --- Slack",
"SLACK_D_COOKIE": "# --- Slack",
"GDRIVE_COOKIES": "# --- Google Drive",
"GDRIVE_SAPISID": "# --- Google Drive",
"TEAMS_SKYPETOKEN": "# --- Microsoft Teams (personal)",
"TEAMS_SESSION_ID": "# --- Microsoft Teams (personal)",
"GRAPH_ACCESS_TOKEN": "# --- Outlook / Microsoft 365",
"OWA_ACCESS_TOKEN": "# --- Outlook / Microsoft 365",
}
return hints.get(env_key, "")
def http_get(url: str, headers: dict) -> int:
"""Make a GET request and return the HTTP status code."""
import ssl
try:
req = urllib.request.Request(url, headers=headers)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with urllib.request.urlopen(req, context=ctx, timeout=8) as resp:
return resp.status
except urllib.error.HTTPError as e:
return e.code
except Exception:
return 0
def http_get_no_redirect(url: str, headers: dict) -> int:
"""GET without following redirects — returns 302 for expired sessions."""
import ssl
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, hdrs, newurl):
return None
try:
opener = urllib.request.build_opener(_NoRedirect())
req = urllib.request.Request(url, headers=headers)
ssl_ctx = ssl.create_default_context()
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
with opener.open(req, timeout=8) as resp:
return resp.status
except urllib.error.HTTPError as e:
return e.code
except Exception:
return 0
FILE:scripts/shared_utils/playwright_sso.py
#!/usr/bin/env python3
"""
SSO session refresher via Playwright.
Opens a headed Chromium window, completes SSO login (auto-completes on managed
machines via enterprise SSO extensions), and captures session tokens/cookies for:
- Grafana session cookie (~8h TTL) → GRAFANA_SESSION in .env
- Slack session token (~8h TTL) → SLACK_XOXC + SLACK_D_COOKIE in .env
- Google Drive session (days/weeks) → ~/.browser_automation/gdrive_auth.json
- Microsoft Teams (personal) session (~24h TTL) → TEAMS_SKYPETOKEN + TEAMS_SESSION_ID in .env
- Outlook / Microsoft 365 work (~1h TTL) → GRAPH_ACCESS_TOKEN + OWA_ACCESS_TOKEN in .env
By default, existing tokens are validated first — the browser only opens if one
or more have expired. Use --force to always refresh.
Usage (CLI):
python3 playwright_sso.py [--env-file PATH] [--force]
python3 playwright_sso.py --slack-only # refresh only Slack credentials
python3 playwright_sso.py --gdrive-only # refresh only Google Drive session
python3 playwright_sso.py --grafana-only # refresh only Grafana session
python3 playwright_sso.py --teams-only # refresh only Microsoft Teams (personal) session
python3 playwright_sso.py --outlook-only # refresh only Outlook / Microsoft 365 tokens
Usage (library):
from playwright_sso import check_tokens, get_grafana_session, get_slack_session, get_teams_session, get_outlook_session
status = check_tokens(grafana_session=..., slack_xoxc=...)
tokens = get_grafana_session() # {"grafana_session": "..."}
tokens = get_slack_session() # {"slack_xoxc": "...", "slack_d_cookie": "..."}
tokens = get_teams_session() # {"teams_skypetoken": "...", "teams_session_id": "..."}
tokens = get_outlook_session() # {"graph_access_token": "...", "owa_access_token": "..."}
Configuration:
Set these in your .env file (see the relevant tool's setup.md under `.env` entries):
GRAFANA_BASE_URL — your Grafana instance URL
SLACK_WORKSPACE_URL — your Slack workspace URL (e.g. https://yourcompany.slack.com/)
Or override the constants below directly.
Requirements:
pip install playwright && playwright install chromium
"""
import argparse
import json
import os
import re
import sys
import time
import urllib.request
import urllib.error
from pathlib import Path
try:
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
except ImportError:
print("Installing playwright...")
os.system(f"{sys.executable} -m pip install playwright -q")
os.system(f"{sys.executable} -m playwright install chromium -q")
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
# ---------------------------------------------------------------------------
# Configuration — set these or override via .env
# ---------------------------------------------------------------------------
def _load_env_var(key: str, default: str) -> str:
"""Load a var from .env file or environment, falling back to default."""
env_file = Path(__file__).parents[2] / ".env"
if env_file.exists():
for line in env_file.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip()
return os.environ.get(key, default)
GRAFANA_BASE_URL = _load_env_var("GRAFANA_BASE_URL", "https://grafana.yourcompany.com")
SLACK_WORKSPACE_URL = _load_env_var("SLACK_WORKSPACE_URL", "https://yourcompany.slack.com/")
TEAMS_URL = "https://teams.live.com/v2/"
GDRIVE_URL = "https://drive.google.com/drive/my-drive"
OUTLOOK_URL = "https://outlook.office.com/mail/"
GDRIVE_AUTH_FILE = Path.home() / ".browser_automation" / "gdrive_auth.json"
DEFAULT_ENV_FILE = Path.home() / ".openclaw" / "tool-connector.env"
# ---------------------------------------------------------------------------
# Token validation (no browser needed)
# ---------------------------------------------------------------------------
def _http_get(url: str, headers: dict) -> int:
"""Make a GET request and return the HTTP status code."""
try:
import ssl
req = urllib.request.Request(url, headers=headers)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with urllib.request.urlopen(req, context=ctx, timeout=8) as resp:
return resp.status
except urllib.error.HTTPError as e:
return e.code
except Exception:
return 0
def _http_get_no_redirect(url: str, headers: dict) -> int:
"""GET without following redirects — returns 302 for expired sessions."""
import ssl
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, hdrs, newurl):
return None
try:
opener = urllib.request.build_opener(_NoRedirect())
req = urllib.request.Request(url, headers=headers)
ssl_ctx = ssl.create_default_context()
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
with opener.open(req, timeout=8) as resp:
return resp.status
except urllib.error.HTTPError as e:
return e.code
except Exception:
return 0
def check_tokens(
grafana_session: str | None = None,
slack_xoxc: str | None = None,
gdrive_sapisid: str | None = None,
gdrive_cookies: str | None = None,
teams_skypetoken: str | None = None,
graph_access_token: str | None = None,
owa_access_token: str | None = None,
) -> dict[str, bool]:
"""
Validate existing tokens with lightweight API calls (no browser).
Returns validity flags: {"grafana_session": bool, "slack_xoxc": bool, "gdrive": bool,
"teams": bool, "outlook_graph": bool, "outlook_owa": bool}
"""
result = {
"grafana_session": False,
"slack_xoxc": False,
"gdrive": False,
"teams": False,
"outlook_graph": False,
"outlook_owa": False,
}
if grafana_session:
status = _http_get(
f"{GRAFANA_BASE_URL}/api/user",
{"Cookie": f"grafana_session={grafana_session}"},
)
result["grafana_session"] = status == 200
if slack_xoxc and not slack_xoxc.startswith("xoxc-your-"):
try:
import ssl, json as _json
req = urllib.request.Request(
"https://slack.com/api/auth.test",
headers={"Authorization": f"Bearer {slack_xoxc}"},
)
ctx2 = ssl.create_default_context()
ctx2.check_hostname = False
ctx2.verify_mode = ssl.CERT_NONE
with urllib.request.urlopen(req, context=ctx2, timeout=8) as resp:
body = _json.loads(resp.read())
result["slack_xoxc"] = body.get("ok") is True
except Exception:
result["slack_xoxc"] = False
if gdrive_sapisid and gdrive_cookies:
import hashlib
ts = str(int(time.time()))
sha1 = hashlib.sha1(f"{ts} {gdrive_sapisid} https://drive.google.com".encode()).hexdigest()
auth = f"SAPISIDHASH {ts}_{sha1}"
status = _http_get(
"https://drive.google.com/drive/v2internal/about?fields=user",
{"Authorization": auth, "Cookie": gdrive_cookies, "X-Goog-AuthUser": "0"},
)
result["gdrive"] = status == 200
if teams_skypetoken and not teams_skypetoken.startswith("your-"):
status = _http_get(
"https://teams.live.com/api/csa/api/v1/teams/users/me",
{"x-skypetoken": teams_skypetoken},
)
result["teams"] = status == 200
if graph_access_token and not graph_access_token.startswith("your-"):
status = _http_get(
"https://graph.microsoft.com/v1.0/me",
{"Authorization": f"Bearer {graph_access_token}"},
)
result["outlook_graph"] = status == 200
if owa_access_token and not owa_access_token.startswith("your-"):
status = _http_get(
"https://outlook.office.com/api/v2.0/me/MailFolders/Inbox?$select=DisplayName",
{"Authorization": f"Bearer {owa_access_token}"},
)
result["outlook_owa"] = status == 200
return result
def load_tokens_from_env(env_path: Path) -> dict[str, str | None]:
"""Read session tokens/cookies from a .env file."""
tokens: dict[str, str | None] = {
"grafana_session": None,
"slack_xoxc": None,
"slack_d_cookie": None,
"gdrive_cookies": None,
"gdrive_sapisid": None,
"teams_skypetoken": None,
"teams_session_id": None,
"graph_access_token": None,
"owa_access_token": None,
}
if not env_path.exists():
return tokens
for line in env_path.read_text().splitlines():
if line.startswith("GRAFANA_SESSION="):
tokens["grafana_session"] = line.split("=", 1)[1].strip()
elif line.startswith("SLACK_XOXC="):
tokens["slack_xoxc"] = line.split("=", 1)[1].strip()
elif line.startswith("SLACK_D_COOKIE="):
tokens["slack_d_cookie"] = line.split("=", 1)[1].strip()
elif line.startswith("GDRIVE_COOKIES="):
tokens["gdrive_cookies"] = line.split("=", 1)[1].strip()
elif line.startswith("GDRIVE_SAPISID="):
tokens["gdrive_sapisid"] = line.split("=", 1)[1].strip()
elif line.startswith("TEAMS_SKYPETOKEN="):
tokens["teams_skypetoken"] = line.split("=", 1)[1].strip()
elif line.startswith("TEAMS_SESSION_ID="):
tokens["teams_session_id"] = line.split("=", 1)[1].strip()
elif line.startswith("GRAPH_ACCESS_TOKEN="):
tokens["graph_access_token"] = line.split("=", 1)[1].strip()
elif line.startswith("OWA_ACCESS_TOKEN="):
tokens["owa_access_token"] = line.split("=", 1)[1].strip()
return tokens
# ---------------------------------------------------------------------------
# Grafana session
# ---------------------------------------------------------------------------
def get_grafana_session() -> dict[str, str]:
"""
Navigate to Grafana in a headed browser, complete SSO login, and return
{"grafana_session": "<cookie value>"}.
On managed machines with enterprise SSO, this completes automatically.
On personal machines, the login page opens — complete it manually once.
"""
print(f" [1/1] Getting Grafana session (navigating to {GRAFANA_BASE_URL})...")
with sync_playwright() as p:
browser = p.chromium.launch(
headless=False,
args=["--window-size=1200,800", "--window-position=100,100"],
)
ctx = browser.new_context(ignore_https_errors=True)
page = ctx.new_page()
page.goto(GRAFANA_BASE_URL, wait_until="networkidle", timeout=60_000)
time.sleep(2)
grafana_cookies = {c["name"]: c["value"] for c in ctx.cookies([GRAFANA_BASE_URL])}
grafana_session = grafana_cookies.get("grafana_session")
if not grafana_session:
# Wait for manual login if needed
print(" Waiting for manual login (3 min timeout)...", flush=True)
for _ in range(90):
time.sleep(2)
grafana_cookies = {c["name"]: c["value"] for c in ctx.cookies([GRAFANA_BASE_URL])}
grafana_session = grafana_cookies.get("grafana_session")
if grafana_session:
break
browser.close()
if not grafana_session:
raise RuntimeError("No grafana_session cookie captured.")
print(f" Grafana session captured ({len(grafana_session)} chars)")
return {"grafana_session": grafana_session}
# ---------------------------------------------------------------------------
# Slack session
# ---------------------------------------------------------------------------
def _extract_slack_session(page, ctx) -> tuple[str, str]:
"""Navigate to Slack workspace and extract the xoxc client token + d cookie."""
page.goto(SLACK_WORKSPACE_URL, wait_until="commit", timeout=30_000)
# Wait for the Slack app to fully load — indicated by the xoxc token appearing
# in localStorage. This handles SSO (auto), manual login, and CAPTCHA flows.
# Polls every 2s for up to 3 minutes.
print(" Waiting for Slack login to complete (up to 3 min)...", flush=True)
xoxc = None
deadline = time.time() + 180
while time.time() < deadline:
time.sleep(2)
try:
xoxc = page.evaluate("""() => {
try {
const cfg = JSON.parse(localStorage.getItem('localConfig_v2') || 'null');
if (cfg && cfg.teams) {
const tid = Object.keys(cfg.teams)[0];
const t = cfg.teams[tid]?.token;
if (t && t.startsWith('xoxc')) return t;
}
} catch(e) {}
for (let i = 0; i < localStorage.length; i++) {
const raw = localStorage.getItem(localStorage.key(i)) || '';
const m = raw.match(/xoxc-[a-zA-Z0-9%-]+/);
if (m) return m[0];
}
return null;
}""")
except Exception:
# Page navigated mid-evaluate — normal during login redirects, just retry
continue
if xoxc:
break
if "slack.com" not in page.url:
raise RuntimeError(f"Slack login timed out — ended up on: {page.url}")
print(f" Page after login: {page.url}", flush=True)
if not xoxc:
raise RuntimeError("No xoxc token found — login may not have completed within 3 minutes.")
all_cookies = ctx.cookies(["https://slack.com", "https://app.slack.com"])
d_cookie = {c["name"]: c["value"] for c in all_cookies}.get("d", "")
if not d_cookie:
raise RuntimeError("No 'd' cookie found after Slack SSO.")
return xoxc, d_cookie
def get_slack_session() -> dict[str, str]:
"""
Open Slack workspace in a headed browser, complete SSO login, and return
{"slack_xoxc": "...", "slack_d_cookie": "..."}.
On managed machines, SSO auto-completes. On personal machines, complete login manually.
Session lifetime: ~8h.
"""
print(f" [1/1] Getting Slack session (navigating to {SLACK_WORKSPACE_URL})...")
with sync_playwright() as p:
browser = p.chromium.launch(
headless=False,
args=["--window-size=900,600", "--window-position=100,100"],
)
ctx = browser.new_context(ignore_https_errors=True)
page = ctx.new_page()
xoxc, d_cookie = _extract_slack_session(page, ctx)
browser.close()
print(f" Slack xoxc captured ({len(xoxc)} chars)")
return {"slack_xoxc": xoxc, "slack_d_cookie": d_cookie}
# ---------------------------------------------------------------------------
# Google Drive session
# ---------------------------------------------------------------------------
def _extract_gdrive_session(page, ctx) -> dict[str, str]:
"""
Navigate to Google Drive, complete Google Workspace SSO, and save storage_state.
IMPORTANT: Raw cookie injection (ctx.add_cookies) triggers Google's CookieMismatch
security check. Playwright's storage_state correctly replays the full browser
session (cookies + fingerprint) and is the only approach that works.
Returns {"gdrive_cookies": "...", "gdrive_sapisid": "..."} and saves the full
session to GDRIVE_AUTH_FILE — that file is what google-drive.md uses for all
subsequent Drive access.
"""
page.goto(GDRIVE_URL, wait_until="commit", timeout=30_000)
try:
page.wait_for_url("https://drive.google.com/**", timeout=60_000)
except PlaywrightTimeout:
if "accounts.google.com" in page.url or "google.com/signin" in page.url:
print(" Google sign-in page — complete login manually (3 min timeout)...", flush=True)
page.wait_for_url("https://drive.google.com/**", timeout=180_000)
else:
raise RuntimeError(f"Unexpected URL after Google Drive navigation: {page.url}")
try:
page.wait_for_load_state("networkidle", timeout=30_000)
except PlaywrightTimeout:
pass
time.sleep(3)
GDRIVE_AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
ctx.storage_state(path=str(GDRIVE_AUTH_FILE))
print(f" storage_state saved to {GDRIVE_AUTH_FILE} ({GDRIVE_AUTH_FILE.stat().st_size} bytes)")
google_cookies = ctx.cookies([
"https://google.com", "https://www.google.com",
"https://drive.google.com", "https://accounts.google.com",
])
cookie_dict = {c["name"]: c["value"] for c in google_cookies}
sapisid = cookie_dict.get("SAPISID", "")
cookie_keys = [
"SID", "HSID", "SSID", "APISID", "SAPISID",
"__Secure-1PSID", "__Secure-3PSID",
"__Secure-1PAPISID", "__Secure-3PAPISID",
"__Secure-1PSIDTS", "__Secure-3PSIDTS",
"__Secure-1PSIDCC", "__Secure-3PSIDCC",
"NID", "ACCOUNT_CHOOSER",
]
cookie_str = "; ".join(
f"{k}={cookie_dict[k]}" for k in cookie_keys
if k in cookie_dict and cookie_dict[k]
)
return {"gdrive_cookies": cookie_str, "gdrive_sapisid": sapisid}
def get_gdrive_session() -> dict[str, str]:
"""
Open Google Drive in a headed browser, complete Google Workspace SSO (~30s),
save full browser session to ~/.browser_automation/gdrive_auth.json, and return
cookie info for .env.
The storage_state file is what the google-drive skill uses to authenticate.
Raw cookie injection does not work for Google (triggers CookieMismatch).
Session lifetime: days to weeks.
"""
print(" [1/1] Getting Google Drive session (Google Workspace SSO, ~30s)...")
with sync_playwright() as p:
browser = p.chromium.launch(
headless=False,
args=["--window-size=1200,800", "--window-position=100,100"],
)
ctx = browser.new_context(ignore_https_errors=True)
page = ctx.new_page()
result = _extract_gdrive_session(page, ctx)
browser.close()
print(f" SAPISID captured ({len(result['gdrive_sapisid'])} chars)")
return result
# ---------------------------------------------------------------------------
# Microsoft Teams (personal) session (teams.live.com)
# ---------------------------------------------------------------------------
def _extract_teams_session(page, ctx) -> tuple[str, str]:
"""
Navigate to Teams (personal), complete Microsoft account login, and extract
the skypetoken (from localStorage/network request headers) + x-ms-session-id.
Teams (personal) uses a private API at teams.live.com/api/ authenticated via
x-skypetoken (not a standard Bearer token). Both tokens are short-lived (~24h).
"""
page.goto(TEAMS_URL, wait_until="commit", timeout=30_000)
print(" Waiting for Teams login to complete (up to 3 min)...", flush=True)
skypetoken = None
session_id = None
deadline = time.time() + 180
captured_headers: list[dict] = []
def _on_request(req):
hdrs = req.headers
if "x-skypetoken" in hdrs:
captured_headers.append(hdrs)
page.on("request", _on_request)
while time.time() < deadline:
time.sleep(2)
# Try extracting from captured network headers first
for hdrs in captured_headers:
t = hdrs.get("x-skypetoken", "")
s = hdrs.get("x-ms-session-id", "")
if t and not t.startswith("your-"):
skypetoken = t
session_id = s or session_id
break
if skypetoken:
break
# Fallback: check localStorage for skypetoken
try:
skypetoken = page.evaluate("""() => {
try {
for (let i = 0; i < localStorage.length; i++) {
const raw = localStorage.getItem(localStorage.key(i)) || '';
const m = raw.match(/"skypeToken":"([^"]+)"/);
if (m) return m[1];
const m2 = raw.match(/"SkypeToken":"([^"]+)"/);
if (m2) return m2[1];
}
} catch(e) {}
return null;
}""")
except Exception:
continue
if skypetoken:
break
if not skypetoken:
raise RuntimeError("No x-skypetoken captured — login may not have completed within 3 minutes.")
if not session_id:
# Generate a UUID as session ID if not captured from headers
import uuid
session_id = str(uuid.uuid4())
print(" x-ms-session-id not captured from headers — generated a new UUID.")
return skypetoken, session_id
def get_teams_session() -> dict[str, str]:
"""
Open Microsoft Teams (personal) (teams.live.com) in a headed browser, complete
Microsoft account login, and return {"teams_skypetoken": "...", "teams_session_id": "..."}.
On managed machines with Azure AD SSO, this completes automatically.
On personal machines, complete Microsoft login once through the browser.
Token lifetime: ~24h.
"""
print(f" [1/1] Getting Microsoft Teams session (navigating to {TEAMS_URL})...")
with sync_playwright() as p:
browser = p.chromium.launch(
headless=False,
args=["--window-size=1200,800", "--window-position=100,100"],
)
ctx = browser.new_context(ignore_https_errors=True)
page = ctx.new_page()
skypetoken, session_id = _extract_teams_session(page, ctx)
browser.close()
print(f" Teams skypetoken captured ({len(skypetoken)} chars)")
return {"teams_skypetoken": skypetoken, "teams_session_id": session_id}
# ---------------------------------------------------------------------------
# Outlook / Microsoft 365 work account session
# ---------------------------------------------------------------------------
def _extract_outlook_session(page, ctx) -> tuple[str, str]:
"""
Navigate to Outlook web (outlook.office.com), complete Azure AD SSO, and
capture two Bearer tokens from network request headers:
- graph_token: from the first graph.microsoft.com request (user photo)
- owa_token: from outlook.office.com/owa/startupdata.ashx (app startup)
On managed machines with macOS Enterprise SSO (Microsoft Intune / Company Portal),
Azure AD login auto-completes in ~30s. On unmanaged machines, the user completes
login once through the browser (~60s).
Token lifetime: ~1h. Both tokens expire together (same Azure AD session).
"""
def _is_jwt(t: str) -> bool:
return isinstance(t, str) and t.count(".") in (2, 4) and len(t) > 100
captured: dict[str, str] = {}
def _on_request(req):
auth = req.headers.get("authorization", "")
if not auth.startswith("Bearer "):
return
t = auth[7:]
if not _is_jwt(t):
return
if "graph.microsoft.com" in req.url and "graph" not in captured:
captured["graph"] = t
elif "outlook.office.com" in req.url and "owa" not in captured:
captured["owa"] = t
page.on("request", _on_request)
page.goto(OUTLOOK_URL, wait_until="commit", timeout=30_000)
print(" Waiting for Outlook login to complete (up to 3 min)...", flush=True)
deadline = time.time() + 180
while time.time() < deadline:
if "graph" in captured and "owa" in captured:
break
time.sleep(1)
if not captured:
raise RuntimeError("No Outlook tokens captured — login may not have completed within 3 minutes.")
graph_token = captured.get("graph", "")
owa_token = captured.get("owa", "")
if not graph_token:
raise RuntimeError("Graph token not captured — Outlook page may not have loaded.")
if not owa_token:
# OWA token fires slightly later; log what we got but don't fail
print(" Warning: OWA token not captured — only Graph token available.")
return graph_token, owa_token
def get_outlook_session() -> dict[str, str]:
"""
Open Outlook web (outlook.office.com) in a headed browser, complete Azure AD SSO,
and return {"graph_access_token": "...", "owa_access_token": "..."}.
- graph_access_token: for graph.microsoft.com endpoints (user profile, people)
- owa_access_token: for outlook.office.com/api/v2.0 endpoints (mail, calendar, contacts)
On managed machines with macOS Enterprise SSO, this completes automatically (~30s).
On personal/unmanaged machines, complete Microsoft 365 login once through the browser.
Token lifetime: ~1h.
"""
print(f" [1/1] Getting Outlook/M365 session (navigating to {OUTLOOK_URL})...")
with sync_playwright() as p:
browser = p.chromium.launch(
headless=False,
args=["--window-size=1200,800", "--window-position=100,100"],
)
ctx = browser.new_context(ignore_https_errors=True)
page = ctx.new_page()
graph_token, owa_token = _extract_outlook_session(page, ctx)
browser.close()
print(f" Graph token captured ({len(graph_token)} chars)")
if owa_token:
print(f" OWA token captured ({len(owa_token)} chars)")
return {"graph_access_token": graph_token, "owa_access_token": owa_token}
# ---------------------------------------------------------------------------
# .env writer
# ---------------------------------------------------------------------------
def update_env_file(env_path: Path, tokens: dict[str, str]) -> None:
"""Write / update token values in .env file."""
if not env_path.exists():
env_path.write_text("")
content = env_path.read_text()
def _upsert(text: str, key: str, value: str, section_hint: str) -> str:
pattern = rf"^({re.escape(key)}=).*$"
new_line = f"{key}={value}"
if re.search(pattern, text, flags=re.MULTILINE):
return re.sub(pattern, new_line, text, flags=re.MULTILINE)
if section_hint and section_hint in text:
return re.sub(
rf"({re.escape(section_hint)}[^\n]*\n)",
r"\1" + new_line + "\n",
text,
)
return text + f"\n{new_line}\n"
if "grafana_session" in tokens:
content = _upsert(content, "GRAFANA_SESSION", tokens["grafana_session"], "# --- Grafana")
if "slack_xoxc" in tokens:
content = _upsert(content, "SLACK_XOXC", tokens["slack_xoxc"], "# --- Slack")
if "slack_d_cookie" in tokens:
content = _upsert(content, "SLACK_D_COOKIE", tokens["slack_d_cookie"], "# --- Slack")
if "gdrive_cookies" in tokens:
content = _upsert(content, "GDRIVE_COOKIES", tokens["gdrive_cookies"], "# --- Google Drive")
if "gdrive_sapisid" in tokens:
content = _upsert(content, "GDRIVE_SAPISID", tokens["gdrive_sapisid"], "# --- Google Drive")
if "teams_skypetoken" in tokens:
content = _upsert(content, "TEAMS_SKYPETOKEN", tokens["teams_skypetoken"], "# --- Microsoft Teams (personal)")
if "teams_session_id" in tokens:
content = _upsert(content, "TEAMS_SESSION_ID", tokens["teams_session_id"], "# --- Microsoft Teams (personal)")
if "graph_access_token" in tokens and tokens["graph_access_token"]:
content = _upsert(content, "GRAPH_ACCESS_TOKEN", tokens["graph_access_token"], "# --- Outlook / Microsoft 365")
if "owa_access_token" in tokens and tokens["owa_access_token"]:
content = _upsert(content, "OWA_ACCESS_TOKEN", tokens["owa_access_token"], "# --- Outlook / Microsoft 365")
env_path.write_text(content)
print(f" Updated {env_path}")
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"--env-file", type=Path, default=DEFAULT_ENV_FILE, metavar="PATH",
help=f"Path to .env file (default: {DEFAULT_ENV_FILE})",
)
parser.add_argument("--force", action="store_true", help="Always refresh, even if tokens are valid")
parser.add_argument("--slack-only", action="store_true", help="Refresh Slack session only")
parser.add_argument("--gdrive-only", action="store_true", help="Refresh Google Drive session only")
parser.add_argument("--grafana-only", action="store_true", help="Refresh Grafana session only")
parser.add_argument("--teams-only", action="store_true", help="Refresh Microsoft Teams (personal) session only")
parser.add_argument("--outlook-only", action="store_true", help="Refresh Outlook / Microsoft 365 tokens only")
args = parser.parse_args()
print("SSO token refresher (Playwright)")
print(f" .env: {args.env_file}")
print()
# Check that required base URLs are configured before opening any browser
issues = []
if not args.slack_only and not args.gdrive_only and not args.teams_only and not args.outlook_only:
if "yourcompany" in GRAFANA_BASE_URL or GRAFANA_BASE_URL == "https://grafana.yourcompany.com":
issues.append(f" GRAFANA_BASE_URL is not set (currently: {GRAFANA_BASE_URL})\n"
f" → Add GRAFANA_BASE_URL=https://grafana.yourcompany.com to .env first")
if not args.grafana_only and not args.gdrive_only and not args.teams_only and not args.outlook_only:
if "yourcompany" in SLACK_WORKSPACE_URL or SLACK_WORKSPACE_URL == "https://yourcompany.slack.com/":
issues.append(f" SLACK_WORKSPACE_URL is not set (currently: {SLACK_WORKSPACE_URL})\n"
f" → Add SLACK_WORKSPACE_URL=https://yourcompany.slack.com/ to .env first")
if issues:
print("⚠ Configuration required before running SSO:\n")
for issue in issues:
print(issue)
print("\nEdit .env, then re-run this script.")
sys.exit(1)
# --- Slack-only ---
if args.slack_only:
if not args.force:
existing = load_tokens_from_env(args.env_file)
validity = check_tokens(slack_xoxc=existing["slack_xoxc"])
if validity["slack_xoxc"]:
print(" SLACK_XOXC: ok — nothing to do.")
return
print(" SLACK_XOXC: expired or missing")
print()
tokens = get_slack_session()
update_env_file(args.env_file, tokens)
print("\nDone.")
for k, v in tokens.items():
print(f" {k}: {v[:50]}...")
return
# --- Google Drive only ---
if args.gdrive_only:
if not args.force:
existing = load_tokens_from_env(args.env_file)
validity = check_tokens(gdrive_sapisid=existing["gdrive_sapisid"], gdrive_cookies=existing["gdrive_cookies"])
if validity["gdrive"]:
print(" GDRIVE: ok — nothing to do.")
return
print(" GDRIVE: expired or missing")
print()
tokens = get_gdrive_session()
update_env_file(args.env_file, tokens)
print("\nDone.")
return
# --- Grafana only ---
if args.grafana_only:
if not args.force:
existing = load_tokens_from_env(args.env_file)
validity = check_tokens(grafana_session=existing["grafana_session"])
if validity["grafana_session"]:
print(" GRAFANA_SESSION: ok — nothing to do.")
return
print(" GRAFANA_SESSION: expired or missing")
print()
tokens = get_grafana_session()
update_env_file(args.env_file, tokens)
print("\nDone.")
for k, v in tokens.items():
print(f" {k}: {v[:50]}...")
return
# --- Teams only ---
if args.teams_only:
if not args.force:
existing = load_tokens_from_env(args.env_file)
validity = check_tokens(teams_skypetoken=existing["teams_skypetoken"])
if validity["teams"]:
print(" TEAMS_SKYPETOKEN: ok — nothing to do.")
return
print(" TEAMS_SKYPETOKEN: expired or missing")
print()
tokens = get_teams_session()
update_env_file(args.env_file, tokens)
print("\nDone.")
for k, v in tokens.items():
print(f" {k}: {v[:50]}...")
return
# --- Outlook only ---
if args.outlook_only:
if not args.force:
existing = load_tokens_from_env(args.env_file)
validity = check_tokens(
graph_access_token=existing["graph_access_token"],
owa_access_token=existing["owa_access_token"],
)
if validity["outlook_graph"] and validity["outlook_owa"]:
print(" GRAPH_ACCESS_TOKEN: ok\n OWA_ACCESS_TOKEN: ok — nothing to do.")
return
print(f" GRAPH_ACCESS_TOKEN: {'ok' if validity['outlook_graph'] else 'expired or missing'}")
print(f" OWA_ACCESS_TOKEN: {'ok' if validity['outlook_owa'] else 'expired or missing'}")
print()
tokens = get_outlook_session()
update_env_file(args.env_file, tokens)
print("\nDone.")
for k, v in tokens.items():
if v:
print(f" {k}: {v[:50]}...")
return
# --- Default: refresh all (Grafana + Slack) ---
if not args.force:
existing = load_tokens_from_env(args.env_file)
print("Checking existing tokens...")
validity = check_tokens(
grafana_session=existing["grafana_session"],
slack_xoxc=existing["slack_xoxc"],
)
grafana_ok = validity["grafana_session"]
slack_ok = validity["slack_xoxc"]
print(f" GRAFANA_SESSION: {'ok' if grafana_ok else 'expired or missing'}")
print(f" SLACK_XOXC: {'ok' if slack_ok else 'expired or missing'}")
print()
if grafana_ok and slack_ok:
print(" All tokens valid — nothing to do.")
return
all_tokens = {}
if not args.force:
if not grafana_ok:
all_tokens.update(get_grafana_session())
if not slack_ok:
all_tokens.update(get_slack_session())
else:
all_tokens.update(get_grafana_session())
all_tokens.update(get_slack_session())
update_env_file(args.env_file, all_tokens)
print("\nDone.")
for k, v in all_tokens.items():
print(f" {k}: {v[:50]}...")
if __name__ == "__main__":
main()