Tunnel orchestration: how slack run works
Slack sends events to public HTTPS URLs. Tunnels (ngrok) expose your localhost to the internet so Slack can reach your dev server without deploying. This lesson shows you how the tunnel script detects your port, updates your manifest automatically, and cleans up on exit so slack run just works.
Outcome
Understand how slack run triggers the tunnel, what the dev workflow does automatically, and when to troubleshoot manifest/token issues.
Fast Track
- Understand what
slack rundoes:.slack/hooks.json→scripts/dev.tunnel.ts→ ngrok + manifest updates - Learn what happens when
NGROK_AUTH_TOKENis missing or manifest source is wrong - See how the script backs up and restores your manifest automatically
How slack run Works
When you run slack run, the Slack CLI looks at .slack/hooks.json:
{
"hooks": {
"get-hooks": "npx -q --no-install -p @slack/cli-hooks slack-cli-get-hooks",
"start": "pnpm --silent dev:tunnel",
"deploy": "vercel deploy --prod"
}
}The start hook triggers scripts/dev.tunnel.ts, which:
- Checks if manifest source is
localin.slack/config.json - Validates
NGROK_AUTH_TOKENexists in.env - Starts ngrok tunnel pointing to your dev server port
- Updates
manifest.jsonwith the new tunnel URL - Backs up the original manifest so it can restore on exit
- Spawns
pnpm devto start your Nitro server - Cleans up tunnel and restores manifest when you hit Ctrl+C
If either check fails (no token or manifest set to remote), it warns you and just runs pnpm dev without the tunnel.
What the Script Does for You
The tunnel script handles three critical tasks:
1. Dynamic Port Detection
const getDevPort = async (): Promise<number> => {
let port = DEFAULT_PORT;
// Check environment variable first
if (process.env.PORT) {
const envPort = Number.parseInt(process.env.PORT, 10);
if (!Number.isNaN(envPort) && envPort > 0) {
port = envPort;
}
}
// Check package.json dev script for --port flag
try {
const packageJson = JSON.parse(await fs.readFile('package.json', 'utf-8'));
const devScript = packageJson.scripts?.dev;
if (devScript) {
const portMatch = devScript.match(/--port\s+(\d+)/);
if (portMatch) {
const scriptPort = Number.parseInt(portMatch[1], 10);
if (!Number.isNaN(scriptPort) && scriptPort > 0) {
port = scriptPort;
}
}
}
} catch {
// Silently ignore package.json read errors
}
return port;
};It reads your port from process.env.PORT or package.json so ngrok always points to the right place.
2. Manifest URL Updates
const updateManifest = async (url: string | null): Promise<ManifestUpdateResult> => {
if (!url) return { updated: false, originalContent: '' };
try {
const file = await fs.readFile(MANIFEST_PATH, 'utf-8');
const manifest: SlackManifest = JSON.parse(file);
const newUrl = `${url}${SLACK_EVENTS_PATH}`;
const currentUrl = manifest.settings.event_subscriptions.request_url;
// Skip if URL hasn't changed
if (currentUrl === newUrl) {
return { updated: false, originalContent: '' };
}
updateManifestUrls(manifest, newUrl);
await fs.writeFile(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
return { updated: true, originalContent: file };
} catch (error) {
throw new Error(`Failed to update manifest: ${error instanceof Error ? error.message : String(error)}`);
}
};Every time the tunnel URL changes, it rewrites manifest.json with the new ngrok URL for event subscriptions, interactivity, and slash commands. It skips updates if the URL hasn't changed.
3. Graceful Cleanup
const cleanup = async (client: ngrok.Listener | null, manifestWasUpdated: boolean) => {
if (client) {
await client.close();
}
if (manifestWasUpdated) {
await restoreManifest();
await removeTempManifest();
}
};When you hit Ctrl+C, it closes the ngrok tunnel and restores your original manifest.json from the backup it created at startup. This prevents you from committing tunnel URLs to git.
Try It
-
Observe the normal flow:
slack runWatch for:
✨ Manifest is set to local in .slack/config.json. Webhook events will be sent to your local tunnel URL: https://...- Your dev server starting
- Open
manifest.jsonand note the ngrok URL inevent_subscriptions.request_url
-
Test the fallback (no tunnel):
- Comment out
NGROK_AUTH_TOKENin.env - Run
slack runagain - Expected warning:
⚠ Manifest is set to local in .slack/config.json but NGROK_AUTH_TOKEN is missing. Webhook events will not be sent to your local server. - Uncomment
NGROK_AUTH_TOKENwhen done
- Comment out
-
Verify cleanup:
- Run
slack run, let it start fully - Note the tunnel URL in
manifest.json - Hit Ctrl+C to stop
- Check
manifest.jsonagain—URL should be restored to original
- Run
Commit
git add -A
git commit -m "docs(tunnel): understand dev workflow and manifest automation
- Trace slack run hook chain through .slack/hooks.json
- Understand port detection, manifest updates, and graceful cleanup
- Test fallback behavior when NGROK_AUTH_TOKEN is missing"Done-When
- Understand how
slack runtriggers the tunnel via.slack/hooks.json - Know what happens when
NGROK_AUTH_TOKENis missing or manifest source is set toremote - Can explain how the script detects port, updates manifest URLs, and restores on exit
- Tested the workflow: run
slack run, see the tunnel start, kill it, confirm manifest restores
What's Next
You've got a working local dev environment. Section 2 digs into the parts you've been using but don't fully understand yet: how manifest.json controls features/scopes/events, how Bolt middleware routes requests, and why acknowledgment timing matters when Slack expects a response in 3 seconds.
Was this helpful?