williamcotton.com

Basic Introduction to Web Pipe

January 3rd, 2026

Web Pipe is a DSL for creating web applications. It might be more useful to consider it more of a configuration language for a web server than anything else.

GET /hello/:world
  |> jq: `{ world: .params.world }`
  |> handlebars: `<p>hello, {{world}}</p>`

describe "hello, world"
  it "calls the route"
    let world = "world"

    when calling GET /hello/{{world}}
    then status is 200
    and selector `p` text equals "hello, {{world}}"

The essence of the language is the pipeline. Each pipeline starts with an HTTP request parsed into a JSON representation, which is then passed along to individual steps. Each step consists of a middleware and a configuration block.

In the above example we define a handler for a GET request and a pipeline that consists of two steps. The first is a step that uses the JQ middleware and the second uses the Handlebars middleware. Their configuration blocks are JQ and Handlebars code, respectively.

In this example we print to the console the JSON provided by the request handler at the start of a pipeline.

GET /echo/:message
  |> debug: `echo`
  |> jq: `{ echo: .params.message }`
{
  "body": {},
  "content_type": "application/json",
  "cookies": {
    "": ""
  },
  "headers": {
    "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "accept-encoding": "gzip, deflate",
    "accept-language": "en-US,en;q=0.9",
    "connection": "keep-alive",
    "host": "localhost:7770",
    "priority": "u=0, i",
    "sec-fetch-dest": "document",
    "sec-fetch-mode": "navigate",
    "sec-fetch-site": "none",
    "upgrade-insecure-requests": "1",
    "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15"
  },
  "ip": "127.0.0.1",
  "method": "GET",
  "originalRequest": {
    "method": "GET",
    "params": {
      "message": "test"
    },
    "query": {}
  },
  "params": {
    "message": "test"
  },
  "path": "/echo/test",
  "query": {}
}

This JSON data structure, known as the "backpack", is passed along to each step in the pipeline. Each step can mutate this data structure if needed, and results will be merged with the backpack. In this very basic example JQ is plucking a parameter from the URL and using it to construct a JSON response to the client.

The response is simply:

{
  "echo": "test"
}

There are various middleware for reading, manipulating, and responding to HTML requests.

GET /lua/:id/example
  |> lua: `
    local id = request.params.id
    local name = request.query.name
    return {
      message = "Hello from Lua!",
      id = id,
      name = name
    }
  `

GET /js/:id/example
  |> js: `
    const id = request.params.id;
    const name = request.query.name;
    return {
      message: "Hello from JavaScript!",
      id: parseInt(id),
      name: name
    };
  `

In both the Lua and JavaScript middleware the "backpack" is an injected "request" object. This should seem very familiar to users of Express.js applications.

For certain middleware, like Postgres or GraphQL, there is no way to access a global request object, so data needs to be fed in through the backpack itself.

GET /team/:id
  |> jq: `{ sqlParams: [.params.id] }`
  |> pg: `SELECT * FROM teams WHERE id = $1`
  |> jq: `{ team: .data.rows[0] }`

There's some syntactic sugar for this that makes it explicit which data is meant for which pipeline step.

GET /team/:id
  |> pg([.params.id]): `SELECT * FROM teams WHERE id = $1`
  |> jq: `{ team: .data.rows[0] }`

Both of these examples are effectively the same, but the later is preferred for most use cases.

This covers the basics. You can use this tool to create APIs or websites. The blog you're reading right now was written using Web Pipe.