feat: direct Slack OAuth integration via Thunder GraphQL#59
Conversation
Instead of sending users to the Amplitude Settings page to manually find and click "Connect to Slack", the wizard now fetches the Slack OAuth install URL directly from Thunder and opens the authorization page in one step. Falls back to the settings page if the API call fails. Also adds auto-detection of existing Slack connections on mount, and fixes the settings URL to use orgId instead of orgName (which was URL-encoding display names with spaces). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
🧙 Wizard CIRun the Wizard CI and test your changes against wizard-workbench example apps by replying with a GitHub comment using one of the following commands: Test all apps:
Test all apps in a directory:
Test an individual app:
Show more apps
Results will be posted here when complete. |
The settings URL was missing the /analytics/ segment, producing URLs like app.amplitude.com/settings/profile which don't resolve to a real page. Fixed both the OUTBOUND_URLS builder and the bin.ts fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Auto-complete race can override user's skip choice
- Added a
cancelledflag set by the useEffect cleanup function, checked both when the fetchSlackConnectionStatus promise resolves and before the delayed markDone fires, preventing the race from overriding a user's skip.
- Added a
- ✅ Fixed: Unused
resolvedOrgNamestate triggers unnecessary API call- Removed the
resolvedOrgNamestate, thefetchAmplitudeUsercall, and its import since they were dead code afterslackSettingsstopped accepting anorgNameparameter.
- Removed the
Or push these changes by commenting:
@cursor push 0c410db9c7
Preview (0c410db9c7)
diff --git a/src/ui/tui/screens/SlackScreen.tsx b/src/ui/tui/screens/SlackScreen.tsx
--- a/src/ui/tui/screens/SlackScreen.tsx
+++ b/src/ui/tui/screens/SlackScreen.tsx
@@ -16,7 +16,6 @@
import { ConfirmationInput } from '../primitives/index.js';
import { Colors } from '../styles.js';
import {
- fetchAmplitudeUser,
fetchSlackInstallUrl,
fetchSlackConnectionStatus,
} from '../../../lib/api.js';
@@ -66,16 +65,15 @@
);
const [phase, setPhase] = useState<Phase>(Phase.Prompt);
- const [resolvedOrgName, setResolvedOrgName] = useState<string | null>(
- store.session.selectedOrgName,
- );
const region = store.session.region ?? 'us';
const isEu = region === 'eu';
const appName = isEu ? 'Amplitude - EU' : 'Amplitude';
- // Fetch org name and check if Slack is already connected on mount.
+ // Check if Slack is already connected on mount — auto-complete if so.
useEffect(() => {
+ let cancelled = false;
+
const credentials = store.session.credentials;
const token = credentials?.idToken ?? credentials?.accessToken;
const orgId = store.session.selectedOrgId;
@@ -86,46 +84,28 @@
} credentials=${credentials ? 'present' : 'null'} region=${region}`,
);
- // Resolve org name if missing (returning users, standalone /slack command).
- if (!resolvedOrgName && credentials) {
- logToFile(`[SlackScreen] fetching org name via API`);
- void fetchAmplitudeUser(token!, region as AmplitudeZone)
- .then((info) => {
- const name = info.orgs[0]?.name ?? null;
- logToFile(
- `[SlackScreen] API returned orgs=${JSON.stringify(
- info.orgs.map((o) => o.name),
- )} using=${name}`,
- );
- setResolvedOrgName(name);
- })
- .catch((err: unknown) => {
- logToFile(
- `[SlackScreen] API fetch failed: ${
- err instanceof Error ? err.message : String(err)
- }`,
- );
- });
- }
-
- // Check if Slack is already connected — auto-complete if so.
if (token && orgId) {
void fetchSlackConnectionStatus(
token,
region as AmplitudeZone,
orgId,
).then((isConnected) => {
+ if (cancelled) return;
logToFile(`[SlackScreen] slackConnectionStatus=${isConnected}`);
if (isConnected) {
setPhase(Phase.Done);
- setTimeout(
- () =>
- markDone(store, SlackOutcome.Configured, standalone, onComplete),
- 1500,
- );
+ setTimeout(() => {
+ if (!cancelled) {
+ markDone(store, SlackOutcome.Configured, standalone, onComplete);
+ }
+ }, 1500);
}
});
}
+
+ return () => {
+ cancelled = true;
+ };
}, []);
const zone = (region ?? 'us') as AmplitudeZone;You can send follow-ups to the cloud agent here.
The `pnpm try slack` command was failing silently because tsx CJS/ESM interop couldn't resolve named exports from wizard-session.js in the full TUI import tree. Rewrote the standalone slack command to skip the TUI entirely — it reads credentials from ~/.ampli.json, orgId from ampli.json, tries the direct Slack OAuth URL via Thunder, and opens the browser directly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use action: "profile" per Ben's Thunder schema (not direct_install) - Prompt user to login when no credentials or orgId found instead of opening a broken URL - Remove dead catch fallback that produced URLs without orgId Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Thunder returns 405 (bearer token auth not yet supported). The fallback to the settings page URL is working correctly. Debug logging will help verify when Thunder adds bearer token support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Thunder requires `Bearer <token>` format while the data API expects a raw JWT. Add the prefix to all Thunder calls (fetchOwnedDashboards, fetchProjectActivationStatus, fetchSlackInstallUrl, fetchSlackConnectionStatus) and keep raw tokens for data API calls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Browser opening blocked behind slow API call
- Moved
opn(settingsUrl)and the phase transition toPhase.Waitingoutside thefetchSlackInstallUrl.then()callback so the browser opens immediately, with the direct Slack OAuth URL fetched in parallel and opened as an upgrade if available.
- Moved
Or push these changes by commenting:
@cursor push 3552b25757
Preview (3552b25757)
diff --git a/src/ui/tui/screens/SlackScreen.tsx b/src/ui/tui/screens/SlackScreen.tsx
--- a/src/ui/tui/screens/SlackScreen.tsx
+++ b/src/ui/tui/screens/SlackScreen.tsx
@@ -144,24 +144,22 @@
const orgId = store.session.selectedOrgId;
const token = credentials?.idToken ?? credentials?.accessToken;
+ // Open the settings URL immediately so the user sees browser activity.
+ logToFile(`[SlackScreen] opening settings URL`);
+ opn(settingsUrl, { wait: false }).catch(() => {});
+ setTimeout(() => setPhase(Phase.Waiting), 800);
+
+ // In parallel, attempt to fetch a direct Slack OAuth URL.
if (token && orgId) {
void fetchSlackInstallUrl(token, zone, orgId, settingsUrl).then(
(directUrl) => {
- const urlToOpen = directUrl ?? settingsUrl;
- setOpenedUrl(urlToOpen);
- logToFile(
- `[SlackScreen] opening ${
- directUrl ? 'direct Slack OAuth URL' : 'settings fallback'
- }`,
- );
- opn(urlToOpen, { wait: false }).catch(() => {});
- setTimeout(() => setPhase(Phase.Waiting), 800);
+ if (directUrl) {
+ setOpenedUrl(directUrl);
+ logToFile(`[SlackScreen] opening direct Slack OAuth URL`);
+ opn(directUrl, { wait: false }).catch(() => {});
+ }
},
);
- } else {
- logToFile(`[SlackScreen] no token/orgId — opening settings fallback`);
- opn(settingsUrl, { wait: false }).catch(() => {});
- setTimeout(() => setPhase(Phase.Waiting), 800);
}
};You can send follow-ups to the cloud agent here.
Thunder validates tokens via Hydra introspection, which only accepts access_tokens — not id_tokens. All Thunder call sites were passing the id_token, causing 401/1003 errors. - Switch all Thunder callers to pass credentials.accessToken - Rename api.ts Thunder function params from idToken to accessToken/token - Add thunderUrl() helper with WIZARD_PROXY_THUNDER_URL env var override for local development against a local Thunder instance - Keep data API calls using id_token (unchanged) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove dead resolvedOrgName state and fetchAmplitudeUser call (slackSettings no longer accepts orgName) - Add cancellation guard to useEffect to prevent auto-complete race from overriding user's skip choice - Open browser immediately on connect, fetch direct Slack OAuth URL in parallel instead of blocking on the API call Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…lone command Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Access token sent to data API expecting id token
- Introduced a separate
dataApiTokenvariable usingcredentials.idToken ?? credentials.accessTokenfor the twofetchWorkspaceEventTypescalls that always hit the Data API, while keepingcredentials.accessTokenforfetchProjectActivationStatuswhich routes to Thunder.
- Introduced a separate
Or push these changes by commenting:
@cursor push 41354ee5dd
Preview (41354ee5dd)
diff --git a/src/ui/tui/screens/DataIngestionCheckScreen.tsx b/src/ui/tui/screens/DataIngestionCheckScreen.tsx
--- a/src/ui/tui/screens/DataIngestionCheckScreen.tsx
+++ b/src/ui/tui/screens/DataIngestionCheckScreen.tsx
@@ -67,6 +67,8 @@
// fetchProjectActivationStatus routes to Thunder when orgId is present,
// so always pass access_token — the function handles the fallback.
const token = credentials.accessToken;
+ // fetchWorkspaceEventTypes always hits the data API which expects id_token.
+ const dataApiToken = credentials.idToken ?? credentials.accessToken;
try {
const status = await fetchProjectActivationStatus(
@@ -88,7 +90,7 @@
// Fall back to the event catalog which includes all event types.
if (session.selectedOrgId && session.selectedWorkspaceId) {
const catalogEvents = await fetchWorkspaceEventTypes(
- token,
+ dataApiToken,
zone,
session.selectedOrgId,
session.selectedWorkspaceId,
@@ -111,7 +113,7 @@
// Fetch cataloged event types from the data API as a proxy for "events arrived"
if (session.selectedOrgId && session.selectedWorkspaceId) {
fetchWorkspaceEventTypes(
- token,
+ dataApiToken,
zone,
session.selectedOrgId,
session.selectedWorkspaceId,You can send follow-ups to the cloud agent here.
credentials.accessToken was being set to storedToken.idToken everywhere in bin.ts and setup-utils.ts. This meant Thunder calls (which validate via Hydra introspection) received an id_token that Hydra rejects. Fix all credential assembly sites to use the real OAuth access_token. The agent-runner already worked around this by re-reading from ~/.ampli.json, but TUI screens relied on the broken credentials. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Change button from "Open settings" to "Connect" - Update prompt to "Connect the Amplitude Slack app to your workspace?" - Try direct OAuth URL before falling back to settings page - Show "Browser opened to Slack for authorization" instead of dumping the long OAuth URL that breaks TUI rendering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fetchWorkspaceEventTypes hits the Data API which expects id_tokens, not Hydra access_tokens. Introduces dataApiToken (idToken ?? accessToken) for those calls while keeping accessToken for Thunder calls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Missed token migration in multi-environment path
- Changed
storedToken.idTokentostoredToken.accessTokenon line 364 of bin.ts to match the migration applied to all other token assignment paths.
- Changed
Or push these changes by commenting:
@cursor push b3644fcb69
Preview (b3644fcb69)
diff --git a/bin.ts b/bin.ts
--- a/bin.ts
+++ b/bin.ts
@@ -361,7 +361,7 @@
);
session.pendingOrgs = userInfo.orgs;
session.pendingAuthIdToken = storedToken.idToken;
- session.pendingAuthAccessToken = storedToken.idToken;
+ session.pendingAuthAccessToken = storedToken.accessToken;
} else {
logToFile(
'[bin] no environments with API keys — showing apiKeyNotice',You can send follow-ups to the cloud agent here.
- Thunder prod URL is core.amplitude.com/t/graphql/org/:orgId (not amplitude.com/graphql/org/) - Fix pendingAuthAccessToken using idToken instead of accessToken in multi-environment code path (bin.ts line 364) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Data API fallback receives wrong token type
- Added optional
dataApiTokenparameter tofetchProjectActivationStatusand passedcredentials.idToken ?? credentials.accessTokenfromDataIngestionCheckScreen, so the data API fallback now receives the correct id_token instead of the access_token.
- Added optional
Or push these changes by commenting:
@cursor push af445ac193
Preview (af445ac193)
diff --git a/src/lib/api.ts b/src/lib/api.ts
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -544,18 +544,21 @@
*
* The query lives in Thunder (the main Amplitude app GraphQL server), served
* at /graphql/org/:orgId. orgId is required to construct the endpoint URL.
+ *
+ * @param token - access_token used for the Thunder (Hydra-validated) path.
+ * @param dataApiToken - raw id_token for the data API fallback when orgId is absent.
*/
export async function fetchProjectActivationStatus(
token: string,
zone: AmplitudeZone,
appId: number | string,
orgId?: string | null,
+ dataApiToken?: string,
): Promise<ProjectActivationStatus> {
const { dataApiUrl } = AMPLITUDE_ZONE_SETTINGS[zone];
// Use the Thunder org-scoped endpoint when orgId is available; fall back to
// the data API (which may not expose this field for all users).
- // Thunder validates access_tokens via Hydra; data API accepts id_tokens.
- // Callers should pass the access_token — it works for both paths.
+ // Thunder validates access_tokens via Hydra; data API accepts raw id_tokens.
const isThunder = !!orgId;
const url = isThunder ? thunderUrl(zone, orgId) : dataApiUrl;
try {
@@ -564,7 +567,7 @@
{ query: ACTIVATION_STATUS_QUERY, variables: { appId: String(appId) } },
{
headers: {
- Authorization: isThunder ? `Bearer ${token}` : token,
+ Authorization: isThunder ? `Bearer ${token}` : dataApiToken ?? token,
'Content-Type': 'application/json',
'User-Agent': WIZARD_USER_AGENT,
},
diff --git a/src/ui/tui/screens/DataIngestionCheckScreen.tsx b/src/ui/tui/screens/DataIngestionCheckScreen.tsx
--- a/src/ui/tui/screens/DataIngestionCheckScreen.tsx
+++ b/src/ui/tui/screens/DataIngestionCheckScreen.tsx
@@ -76,6 +76,7 @@
zone,
appId,
session.selectedOrgId,
+ dataApiToken,
);
logToFile(
`[DataIngestionCheck] poll result: hasAnyEvents=${status.hasAnyEvents} hasDetSource=${status.hasDetSource}`,You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 1e7f0e7. Configure here.
…nStatus When orgId is null, fetchProjectActivationStatus falls back to the Data API which expects idToken, not accessToken. Add optional dataApiToken param so callers can provide the correct token for that path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…t opts Replace implicit orgId-based routing with an explicit options object that always routes to Thunder. The Data API fallback path was dead code — both callers always had orgId available. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>


Summary
fetchSlackInstallUrl()API call to get the direct Slack OAuth URL from Thunder'sslackInstallUrlGraphQL query (action:profile) — currently blocked by Thunder returning 405 (bearer token auth not yet supported)fetchSlackConnectionStatus()to auto-detect existing Slack connections and skip the setup flow/analytics/org/:orgId/settings/profilepath instead of URL-encoding org display namespnpm try slackcommand to work without the full TUI (avoids tsx CJS/ESM interop issue) and requires loginBlocked on
Test plan
pnpm try slack→ openshttps://app.amplitude.com/analytics/org/{orgId}/settings/profilepnpm try slackwith no credentials → shows "No Amplitude session found" messagetail -f ~/.amplitude/wizard-debug.log | grep Slackshows Thunder 405 response (expected until backend support lands)/slack→ verify SlackScreen renders and opens settings URLJira: https://amplitude.atlassian.net/browse/AMP-XXXXX
🤖 Generated with Claude Code
Note
Medium Risk
Moderate risk because it changes which token type (access vs id token) is used for multiple API calls and switches Thunder endpoints/URLs, which could break activation checks or Slack setup if assumptions differ across regions/environments.
Overview
Adds Thunder-backed Slack setup support by introducing
fetchSlackInstallUrl(direct Slack OAuth URL) andfetchSlackConnectionStatus(skip flow when already connected), with graceful fallback to opening the Amplitude settings page.Standardizes Thunder GraphQL calls to use Bearer
access_token(notid_token), updates activation-status fetching to require anorgIdand always hit the org-scoped Thunder endpoint, and adjusts several bootstrap/CI credential paths to store and passstoredToken.accessTokenconsistently.Updates URL plumbing to point at the new Thunder base (
core.amplitude.* /t/graphql/org/) and fixes Slack settings deep-links to use/analytics/org/:orgId/settings/profile(no org-name encoding). The standaloneslackCLI command is rewritten to avoid the TUI and handle CJS/ESM interop via a small dynamic-import normalizer.Reviewed by Cursor Bugbot for commit 6fa3e10. Bugbot is set up for automated code reviews on this repo. Configure here.