Skip to content
Esc
navigateopen⌘Jpreview
On this page

Writing rules

The Go SDK: reading requests, returning actions, patching headers, and composing logic with chains.

A rule is a plain Go package exporting one function:

package myrule

import "github.com/ethndotsh/switchboard/sdk"

func Handle(req sdk.Request) sdk.Action {
	return sdk.Next()
}

switchboard build generates the TinyGo/Wasm export wrapper around it, so the package itself stays ordinary Go. It can span multiple files, use helpers, and be unit-tested like any other package.

Reading the request

sdk.Request exposes read accessors; each one fetches only what it asks for, so cheap rules stay cheap:

req.Method()              // "GET"
req.Path()                // "/admin/users"
req.Host()                // "example.com"
req.Query("utm_source")   // one query parameter
req.Header("x-user-role") // first value, case-insensitive
req.Cookie("session")     // one cookie value
req.ClientIP()            // adapter-resolved, honors Caddy trusted_proxies
req.TLS()                 // true behind TLS

The full list, including RawQuery, Scheme, Protocol, RemoteAddr, and HeaderValues, is in the SDK reference.

Returning an action

Five constructors cover every decision:

Constructor Meaning
sdk.Next() Continue to the upstream (optionally with modifications).
sdk.Deny(status) Reject the request.
sdk.Redirect(status, location) Send the client elsewhere.
sdk.Rewrite(path) Change the path before proxying.
sdk.Respond(status, body) Serve a small synthetic response directly.

Every action supports chainable builders:

return sdk.Next().
	SetRequestHeader("x-tenant", tenant).       // mutate the upstream request
	SetResponseHeader("x-frame-options", "DENY"). // mutate the client response
	RewritePath("/v2" + req.Path()).
	SetMetadata("backend", "v2").               // exposed as proxy variables
	WithReason("v2-migration")                  // shows up in logs, tests, replay

Request-header and response-header operations are explicitly separate calls (SetRequestHeader vs SetResponseHeader), each with Set, Add, and Delete variants.

Composing with chains

sdk.Chain runs rules in order, threading the request through each:

func Handle(req sdk.Request) sdk.Action {
	return sdk.Chain(req,
		BlockInternalPaths,
		RewriteLegacyPaths,
		AddRuleHeader,
	)
}

The semantics:

  • A rule that returns sdk.Next() accumulates its request patch, response headers, metadata, and reason, and the chain continues.
  • Later rules observe the request as rewritten so far: if an earlier rule rewrote the path, req.Path() returns the new one.
  • The first Deny, Redirect, or Respond short-circuits the chain and carries all accumulated state with it.

This keeps each concern in its own small, individually testable function.

Patterns

Auth gate

func Handle(req sdk.Request) sdk.Action {
	if strings.HasPrefix(req.Path(), "/admin") && req.Header("x-user-role") != "admin" {
		return sdk.Deny(401).WithReason("admin-auth-required")
	}
	return sdk.Next()
}

Redirect map

var legacy = map[string]string{
	"/old-pricing": "/pricing",
	"/docs/v1":     "/docs",
}

func Handle(req sdk.Request) sdk.Action {
	if to, ok := legacy[req.Path()]; ok {
		return sdk.Redirect(301, to).WithReason("legacy-redirect")
	}
	return sdk.Next()
}

Maintenance mode

func Handle(req sdk.Request) sdk.Action {
	if req.Path() == "/health" {
		return sdk.Next()
	}
	return sdk.Respond(503, maintenancePage).
		SetResponseHeader("retry-after", "3600").
		WithReason("maintenance")
}

Backend selection via metadata Caddy only

Rules don’t proxy; they decide. SetMetadata hands the decision to the surrounding proxy, which routes on it; see Caddy integration for the full canary example:

func Handle(req sdk.Request) sdk.Action {
	if bucket(req.Cookie("user_id")) < 10 {
		return sdk.Next().SetMetadata("backend", "v2").WithReason("v2-canary")
	}
	return sdk.Next().SetMetadata("backend", "v1").WithReason("stable")
}

The repository ships runnable versions of all of these, each with a behavioral test suite, under examples/.

Constraints to keep in mind

  • No bodies, no network, no state. Rules see request metadata only, cannot call out, and remember nothing between invocations; see Security & reliability.
  • TinyGo, not gc Go. Rules compile with TinyGo; most of the standard library works, but avoid reflection-heavy dependencies.
  • Budgets apply. Emitted values count against max_action_bytes and max_header_ops; a rule that emits 40 headers will be rejected, not truncated.

Last updated on July 17, 2026

Was this page helpful?