Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/nullclaw/nullclaw/llms.txt

Use this file to discover all available pages before exploring further.

Tunnels expose your local NullClaw gateway to the internet without port forwarding or firewall configuration.

Why Tunnels?

Webhook-based channels (Telegram, Discord, Slack) require a public HTTPS URL to receive events. Tunnels provide this by:
  1. Establishing an outbound connection from your gateway to a tunnel server
  2. The tunnel server provides a public HTTPS URL
  3. Incoming webhook requests are forwarded through the tunnel to your local gateway
Telegram → https://abc123.ngrok.io → Tunnel Server → Your Gateway (localhost:3000)

Supported Providers

NullClaw supports multiple tunnel providers:

Cloudflare Tunnel

Free, unlimited bandwidth, zero-trust security

ngrok

Popular, reliable, generous free tier

Tailscale Funnel

Tailnet-based, secure, funnel mode for public access

Custom

Bring your own tunnel (bore, localtunnel, etc.)

Configuration

Add tunnel configuration to ~/.nullclaw/config.json:
{
  "tunnel": {
    "provider": "cloudflare",
    "cloudflare": {
      "token": "your-cloudflare-tunnel-token"
    }
  }
}

Cloudflare Tunnel

Cloudflare Tunnel (formerly Argo Tunnel) provides free, unlimited bandwidth with zero-trust security.

Prerequisites

  1. Cloudflare account (free)
  2. Install cloudflared CLI:
brew install cloudflared  # macOS
# or
wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64

Setup

1

Login to Cloudflare

cloudflared tunnel login
This opens a browser to authenticate with Cloudflare.
2

Create tunnel

cloudflared tunnel create nullclaw
This generates a tunnel token.
3

Get tunnel token

cloudflared tunnel token nullclaw
Copy the token (starts with eyJ...).
4

Add to config

{
  "tunnel": {
    "provider": "cloudflare",
    "cloudflare": {
      "token": "eyJ..."
    }
  }
}

How It Works

NullClaw spawns cloudflared as a child process:
cloudflared tunnel --no-autoupdate run --token TOKEN --url http://localhost:3000
The tunnel URL is extracted from stderr output:
https://abc123.trycloudflare.com

Implementation

From src/tunnel.zig:
pub const CloudflareTunnel = struct {
    token: []const u8,
    allocator: std.mem.Allocator,
    state: TunnelState = .stopped,
    url: ?[]const u8 = null,
    child: ?std.process.Child = null,

    pub fn start(self: *CloudflareTunnel, local_port: u16) ![]const u8 {
        self.state = .starting;
        
        const local_url = try std.fmt.allocPrint(
            self.allocator,
            "http://localhost:{d}",
            .{local_port},
        );
        defer self.allocator.free(local_url);

        var child = std.process.Child.init(
            &.{ "cloudflared", "tunnel", "--no-autoupdate", "run",
                "--token", self.token, "--url", local_url },
            self.allocator,
        );
        child.spawn() catch return error.ProcessSpawnFailed;
        
        // Extract URL from stderr...
        self.state = .running;
        return self.url.?;
    }
};

ngrok

ngrok is a popular tunnel service with a generous free tier.

Prerequisites

  1. ngrok account (free): https://ngrok.com/signup
  2. Install ngrok CLI:
brew install ngrok  # macOS
# or
curl -s https://ngrok-agent.s3.amazonaws.com/ngrok.asc | sudo tee /etc/apt/trusted.gpg.d/ngrok.asc
echo "deb https://ngrok-agent.s3.amazonaws.com buster main" | sudo tee /etc/apt/sources.list.d/ngrok.list
sudo apt update && sudo apt install ngrok

Setup

1

Get auth token

Login to ngrok dashboard and copy your auth token from: https://dashboard.ngrok.com/get-started/your-authtoken
2

Add to config

{
  "tunnel": {
    "provider": "ngrok",
    "ngrok": {
      "auth_token": "your-ngrok-auth-token",
      "domain": "my-app.ngrok.io"  // optional, requires paid plan
    }
  }
}

How It Works

NullClaw spawns ngrok:
ngrok http 3000 --authtoken TOKEN --log stdout --log-format logfmt
The tunnel URL is extracted from stdout:
url=https://abc123.ngrok.io

Custom Domain

With a paid ngrok plan, you can use a custom domain:
{
  "tunnel": {
    "provider": "ngrok",
    "ngrok": {
      "auth_token": "your-token",
      "domain": "nullclaw.example.com"
    }
  }
}

Tailscale Funnel

Tailscale Funnel exposes a service on your tailnet to the public internet.

Prerequisites

  1. Tailscale account (free): https://tailscale.com/
  2. Install Tailscale:
brew install tailscale  # macOS
# or
curl -fsSL https://tailscale.com/install.sh | sh
  1. Login to Tailscale:
tailscale up

Setup

1

Enable funnel mode

{
  "tunnel": {
    "provider": "tailscale",
    "tailscale": {
      "funnel": true,
      "hostname": "myhost.tail-scale.ts.net"  // optional
    }
  }
}

Funnel vs Serve

  • tailscale serve: Exposes to your tailnet only (private)
  • tailscale funnel: Exposes to public internet (requires funnel mode)
Set "funnel": true for public webhooks.

How It Works

NullClaw spawns:
tailscale funnel 3000  # public
# or
tailscale serve 3000  # tailnet-only
The URL is constructed from your Tailscale hostname:
https://myhost.tail-scale.ts.net

Custom Tunnels

Bring your own tunnel provider (bore, localtunnel, etc.).

Configuration

{
  "tunnel": {
    "provider": "custom",
    "custom": {
      "start_command": "bore local {port} --to bore.pub",
      "health_url": "http://localhost:4040/api/tunnels",  // optional
      "url_pattern": "https://.*\\.bore\\.pub"  // optional
    }
  }
}

Placeholders

  • {port}: Replaced with gateway port (e.g., 3000)
  • {host}: Replaced with localhost

How It Works

NullClaw:
  1. Replaces placeholders in start_command
  2. Spawns the command as a child process
  3. Reads stdout for a URL matching https://...
Example with bore:
bore local 3000 --to bore.pub
Output:
Listening on https://abc123.bore.pub
NullClaw extracts https://abc123.bore.pub.

No Tunnel (Local Only)

Set provider to "none" to disable tunnels:
{
  "tunnel": {
    "provider": "none"
  }
}
The gateway only accepts connections on localhost. Webhooks will not work.

Tunnel State

NullClaw tracks tunnel state:
pub const TunnelState = enum {
    stopped,
    starting,
    running,
    error_state,
};
Check tunnel status:
nullclaw gateway status

Security Considerations

Tunnels expose your gateway to the internet. Ensure you:
  • Use webhook secret tokens (Telegram, Discord, Slack)
  • Enable pairing mode for interactive sessions
  • Review security policies in ~/.nullclaw/config.json
  • Monitor tunnel logs for unauthorized access

Webhook Secrets

Always configure webhook secret tokens: Telegram:
curl -X POST "https://api.telegram.org/bot<TOKEN>/setWebhook" \
  -H "content-type: application/json" \
  -d '{
    "url": "https://abc123.ngrok.io/telegram",
    "secret_token": "your-secret-token"
  }'
Discord:
{
  "channels": {
    "discord": {
      "verification_key": "your-discord-public-key"
    }
  }
}

Tunnel Lifecycle

Start

var tunnel = try createTunnel(config);
const public_url = try tunnel.start("localhost", 3000);
std.debug.print("Tunnel URL: {s}\n", .{public_url});

Stop

tunnel.stop();
This kills the child process and cleans up resources.

URL Extraction

NullClaw scans tunnel process output for HTTPS URLs:
fn extractUrl(output: []const u8) ?[]const u8 {
    var start: usize = 0;
    while (start < output.len) {
        if (std.mem.indexOfPos(u8, output, start, "https://")) |idx| {
            const end = blk: {
                var e = idx;
                while (e < output.len and 
                       output[e] != ' ' and 
                       output[e] != '\n' and 
                       output[e] != '\r') : (e += 1) {}
                break :blk e;
            };
            if (end > idx + 8) return output[idx..end];
            start = end;
        } else break;
    }
    return null;
}

Troubleshooting

Tunnel binary not found

Ensure the tunnel CLI is in your PATH:
which cloudflared
which ngrok
which tailscale
Install the missing binary:
brew install cloudflared ngrok tailscale

URL not extracted

NullClaw reads tunnel process output to find the URL. If extraction fails:
  1. Check tunnel process logs
  2. Verify the tunnel is actually starting
  3. Ensure output contains https:// URL

Tunnel disconnects

Free tunnel plans may have:
  • Idle timeouts
  • Bandwidth limits
  • Connection limits
Consider upgrading to a paid plan for production use.

Port already in use

If the gateway port is already bound:
lsof -ti:3000 | xargs kill
Or change the gateway port:
{
  "gateway": {
    "port": 3001
  }
}

Implementation Reference

The tunnel system is implemented in src/tunnel.zig with a vtable-based architecture:
pub const TunnelAdapter = struct {
    ptr: *anyopaque,
    vtable: *const VTable,

    pub const VTable = struct {
        start: *const fn (ptr: *anyopaque, local_port: u16) TunnelError![]const u8,
        stop: *const fn (ptr: *anyopaque) void,
        public_url: *const fn (ptr: *anyopaque) ?[]const u8,
        provider_name: *const fn (ptr: *anyopaque) []const u8,
        is_running: *const fn (ptr: *anyopaque) bool,
    };
};
Each provider implements this interface:
  • NoneTunnel (no-op)
  • CloudflareTunnel
  • NgrokTunnel
  • TailscaleTunnel
  • CustomTunnel
See ~/workspace/source/src/tunnel.zig for full implementation.