Vercel Logo

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

  1. Understand what slack run does: .slack/hooks.jsonscripts/dev.tunnel.ts → ngrok + manifest updates
  2. Learn what happens when NGROK_AUTH_TOKEN is missing or manifest source is wrong
  3. 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:

.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:

  1. Checks if manifest source is local in .slack/config.json
  2. Validates NGROK_AUTH_TOKEN exists in .env
  3. Starts ngrok tunnel pointing to your dev server port
  4. Updates manifest.json with the new tunnel URL
  5. Backs up the original manifest so it can restore on exit
  6. Spawns pnpm dev to start your Nitro server
  7. 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

scripts/dev.tunnel.ts
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

scripts/dev.tunnel.ts
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

scripts/dev.tunnel.ts
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

  1. Observe the normal flow:

    slack run

    Watch 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.json and note the ngrok URL in event_subscriptions.request_url
  2. Test the fallback (no tunnel):

    • Comment out NGROK_AUTH_TOKEN in .env
    • Run slack run again
    • 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_TOKEN when done
  3. Verify cleanup:

    • Run slack run, let it start fully
    • Note the tunnel URL in manifest.json
    • Hit Ctrl+C to stop
    • Check manifest.json again—URL should be restored to original

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 run triggers the tunnel via .slack/hooks.json
  • Know what happens when NGROK_AUTH_TOKEN is missing or manifest source is set to remote
  • 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.