---
title: "Local Development Tunnel"
description: "The tunnel script is magic until it breaks. Learn how it detects existing tunnels, rewrites manifests, and handles port conflicts. Master the developer experience patterns that make local development smooth."
canonical_url: "https://vercel.com/academy/slack-agents/tunnel-orchestration"
md_url: "https://vercel.com/academy/slack-agents/tunnel-orchestration.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-04-09T11:34:40.944Z"
content_type: "lesson"
course: "slack-agents"
course_title: "Slack Agents on Vercel with the AI SDK"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Local Development Tunnel

# 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.json` → `scripts/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`:

```json title=".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

```typescript title="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

```typescript title="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

```typescript title="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:**
   ```bash
   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

```bash
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

- [x] Understand how `slack run` triggers the tunnel via `.slack/hooks.json`
- [x] Know what happens when `NGROK_AUTH_TOKEN` is missing or manifest source is set to `remote`
- [x] Can explain how the script detects port, updates manifest URLs, and restores on exit
- [x] 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.


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
