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:
- Establishing an outbound connection from your gateway to a tunnel server
- The tunnel server provides a public HTTPS URL
- 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
- Cloudflare account (free)
- Install
cloudflared CLI:
brew install cloudflared # macOS
# or
wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64
Setup
Login to Cloudflare
This opens a browser to authenticate with Cloudflare. Create tunnel
cloudflared tunnel create nullclaw
This generates a tunnel token.Get tunnel token
cloudflared tunnel token nullclaw
Copy the token (starts with eyJ...).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
- ngrok account (free): https://ngrok.com/signup
- 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
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
- Tailscale account (free): https://tailscale.com/
- Install Tailscale:
brew install tailscale # macOS
# or
curl -fsSL https://tailscale.com/install.sh | sh
- Login to Tailscale:
Setup
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:
- Replaces placeholders in
start_command
- Spawns the command as a child process
- 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:
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
This kills the child process and cleans up resources.
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
NullClaw reads tunnel process output to find the URL. If extraction fails:
- Check tunnel process logs
- Verify the tunnel is actually starting
- 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.