2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00
2026-05-23 12:45:32 -05:00

MACH

Why MACH

MACH (Modern Asynchronous C Hypermedia) is a declarative framework for building asynchronous web applications in C23.

  • No build configuration. Compilation, hot reload, and hmr are handled by the framework. There are no build scripts, package managers, or ORMs to set up. Templates and SQL live in files that are discovered and registered automatically.
  • Memory, concurrency, and I/O managed by the framework. Application code does not call malloc/free or manage threads, mutexes, or locks. Database queries run as prepared statements. Pipeline steps emit OpenTelemetry spans, logs, and errors automatically.
  • Durable tasks and events. Both are persisted. If the process crashes, incomplete tasks resume at the step where they left off and undelivered events replay on the next boot.
  • Bundled modules. SSE plus modules for Datastar, HTMX, Tailwind, SQLite, Postgres, MySQL, Redis/Valkey, DuckDB, and auth. Multi-tenant database support is built in.

Table of Contents


Quick Start

Everything runs in Docker, no other local dependencies are required.

mkdir myapp && cd myapp
wget https://docker.nightshadecoder.dev/mach/compose.yml

# Dev server on :3000, telemetry on :4000
# Includes file watching, auto compilation, hot code reloading, HMR
docker compose up

Create main.c with the example below. MACH watches for changes and hot-reloads on save. Use your own editor, or attach to the built-in TUI with docker compose attach mach for an integrated environment with editor, AI, LSP, and console.

#include <mach.h>

void mach(){
  resource("home", "/",
    .get = {
      mustache(.body = "<h1>Hello, world!</h1>")
    }
  );
}

The mach() function runs once at boot and declares the application by calling into the MACH API. Each call configures MACH: here, a single resource() declares the home endpoint that maps / to a GET pipeline whose only step renders inline HTML. For a step-by-step walkthrough, see the Guide.


Philosophy

An application is a data transformation: input arrives, goes through transformation, and leaves as output. MACH is a thin pipeline wrapper around that path, built to get from input to output with as little overhead as possible.

Everything inside the pipeline is a standard format or tool. Assets are raw SQL, JSON, Markdown, and HTML/CSS/JS via Mustache templates, not ORMs or bespoke formats. Business logic is plain C. The tooling is the same: lldb for debugging, Playwright and Criterion for testing, OpenTelemetry for observability, and OpenCode AI assistant. MACH uses these standard tools and formats and arranges them into pipelines, ordered lists of steps that turn a request into a response.

Everything is a String

The web is largely text: HTTP, HTML, JSON, SQL. MACH takes this literally. The pipeline context stores and passes data as arena-backed strings; there is no intermediate parsing or serialization layer. Request parameters are not parsed into typed structs and objects are not serialized back to JSON. Data flows through the pipeline as strings, interpolated into SQL, templates, and URLs with {{context_key}}.

When business logic needs a specific type, convert explicitly inside an exec() step.

CLAD

MACH is organized around four principles.

  • (C)omposable: small, independent steps chain into feature pipelines.
  • (L)ocality of Behavior: the behavior of a unit of code is apparent from reading it. SQL, templates, and behavior for a feature live together rather than across separate model, view, and controller trees.
  • (A)utonomous: modules are self-contained: own schemas, migrations, seeds, routes, UI, and logic. The compiler enforces strict boundaries.
  • (D)omain Based: each module owns one slice of the app. A todos module defines everything related to todos and nothing else.

CLAD is influenced by:


Guide

This walkthrough builds a todo app one concept at a time. Step 1 shows the starting files in full; every step after shows the changes as a diff, so you see exactly what to add or edit. It stays high-level and points to the Reference for the full options on each step, helper, and field. MACH discovers templates and SQL automatically and works out which module owns each one from its location (see Modules and Composition).

1. Pages and Templates

mach() is the declarative entry point: each resource(...) call declares a named URL endpoint, and each verb pipeline is a list of steps. The mustache("home") step renders the template asset home, which is the file home.mustache.html. Reference resources by name with {{url:...}}, never by hard-coded path.

Both pages share the same chrome, so the home page doubles as the layout: it declares the nav and a {{$body}} block whose default content is the welcome page, and the todos page extends it with {{<home}}...{{/home}}, overriding that block. Any template that declares a {{$block}} can be a parent; there is no special layout type.

home.mustache.html

<html>
  <body>
    <nav><a href='{{url:home}}'>Home</a> · <a href='{{url:todos}}'>My Todos</a></nav>
    <main>{{$body}}<h1>Welcome</h1>{{/body}}</main>
  </body>
</html>

todos.mustache.html

{{<home}}
  {{$body}}
    <h1>My Todos</h1>
    <p>Nothing yet.</p>
  {{/body}}
{{/home}}

main.c

#include <mach.h>

void mach(){
  resource("home", "/", .get = { mustache("home") });
  resource("todos", "/todos", .get = { mustache("todos") });
}

See Resource Pipelines for verbs, .all, and .mime, and Template Helpers for the full template feature set.

2. Show Data

Activate the engine with sqlite_config(), declare a database with sqlite_database(...), and read from it with a sqlite_query() step. SQL lives in files too, including seed data: get_todos.sql becomes the asset get_todos, seed_todos.sql becomes seed_todos.

Three new SQL files:

create_todos_table.sql

CREATE TABLE todos (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL
);

seed_todos.sql

INSERT INTO todos(title) VALUES('Learn MACH');

get_todos.sql

select id, title from todos;

Render the rows MACH stores under todos_data (the data key is kept separate from the todos template name):

todos.mustache.html

 {{<home}}
   {{$body}}
     <h1>My Todos</h1>
-    <p>Nothing yet.</p>
+    <ul>{{#todos_data}}<li>{{title}}</li>{{/todos_data}}</ul>
   {{/body}}
 {{/home}}

Wire up the engine, database, and query:

main.c

 #include <mach.h>
+#include <sqlite.h>

 void mach(){
+  sqlite_config();
+
+  sqlite_database(
+    .name = "todos_db",
+    .connect = "file:todos.db?mode=rwc",
+    .migrations = {"create_todos_table"},
+    .seeds = {"seed_todos"}
+  );
+
   resource("home", "/", .get = { mustache("home") });
-  resource("todos", "/todos", .get = { mustache("todos") });
+
+  resource("todos", "/todos",
+    .get = {
+      sqlite_query({"todos_db", "get_todos", "todos_data"}),
+      mustache("todos")
+    }
+  );
 }

The query's parameters are the database name, the SQL asset, and the context key the result table is stored under (todos_data). The .seeds entry is the asset name of seed_todos.sql. The template walks the result with {{#todos_data}}...{{/todos_data}}. Migrations run on first connection; seeds are idempotent. See Databases and query.

3. Accept Input

Add a .post verb that validates, inserts, and redirects (POST-redirect-GET). A resource-scoped .errors handler re-renders the form when validation fails.

create_todo.sql

insert into todos(title) values({{title}});

Add the form, repopulating the field and showing the error after a failed submit:

todos.mustache.html

 {{<home}}
   {{$body}}
     <h1>My Todos</h1>
     <ul>{{#todos_data}}<li>{{title}}</li>{{/todos_data}}</ul>
+    <form method='post' action='{{url:todos}}'>
+      {{csrf:input}}
+      <input name='title' value='{{input:title}}'>
+      {{#error:title}}<span>{{error_message:title}}</span>{{/error:title}}
+      <button>Add</button>
+    </form>
   {{/body}}
 {{/home}}

Grow the todos resource with a .post and an .errors handler:

main.c

   resource("todos", "/todos",
     .get = {
       sqlite_query({"todos_db", "get_todos", "todos_data"}),
       mustache("todos")
-    }
+    },
+    .post = {
+      input({"title", m_not_empty}),
+      sqlite_query({"todos_db", "create_todo"}),
+      redirect("todos")
+    },
+    .errors = {
+      {m_bad_request, {reroute("todos")}}
+    }
   );

input() validates and promotes title to app scope; the {{title}} in create_todo.sql is bound as a prepared-statement parameter, not spliced. On failure, m_bad_request triggers the handler, which reroutes back into the GET pipeline in-process. The input: and error: scopes survive the reroute, so the form repopulates with {{input:title}} and shows {{error_message:title}}. See input, Error and Repair Pipelines, and redirect and reroute.

4. Nested Data

A /todos/:id page fetches a todo and its comments concurrently, then nests the comments inside the todo with join(). Comments live in the same domain as todos, so the new table is another migration on todos_db.

Three new SQL files and one new template:

create_comments_table.sql

CREATE TABLE comments (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  todo_id INTEGER NOT NULL REFERENCES todos(id),
  body TEXT NOT NULL
);

get_todo.sql

select id, title from todos where id = {{id}};

get_comments.sql

select id, todo_id, body from comments where todo_id = {{id}};

Enter {{#todo}} first; after the join, comments lives inside each todo record:

todo_detail.mustache.html

{{<home}}
  {{$body}}
    {{#todo}}
      <h1>{{title}}</h1>
      <h2>Comments</h2>
      <ul>{{#comments}}<li>{{body}}</li>{{/comments}}</ul>
    {{/todo}}
  {{/body}}
{{/home}}

Register the migration and add a todo resource:

main.c

   sqlite_database(
     .name = "todos_db",
     .connect = "file:todos.db?mode=rwc",
-    .migrations = {"create_todos_table"},
+    .migrations = {"create_todos_table", "create_comments_table"},
     .seeds = {"seed_todos"}
   );
+  resource("todo", "/todos/:id",
+    .get = {
+      input({"id", m_integer}),
+      sqlite_query(
+        {"todos_db", "get_todo", "todo", .must_exist = true},
+        {"todos_db", "get_comments", "comments"}
+      ),
+      join(.parent_key = "todo", .field_key = "id", .child_key = "comments", .child_field_key = "todo_id", .join_field_key = "comments"),
+      mustache("todo_detail")
+    }
+  );

Both queries in one sqlite_query() call run concurrently. join() lifts comments inside each todo record, so the template reaches {{#comments}} from within {{#todo}}. .must_exist = true returns a 404 when the id matches nothing. Link each list item to its page with <a href='{{url:todo}}'>. See join and query.

5. Tasks

A task is a named pipeline that runs off the request path on a task reactor. Define it once with an optional .cron, and enqueue on-demand runs with the task("name") step.

Two new SQL files:

create_daily_stats_table.sql

CREATE TABLE daily_stats (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  todo_count INTEGER NOT NULL
);

record_daily_stats.sql

insert into daily_stats(todo_count) select count(*) from todos;

Register the migration, define the task, and enqueue it from the POST:

main.c

   sqlite_database(
     .name = "todos_db",
     .connect = "file:todos.db?mode=rwc",
-    .migrations = {"create_todos_table", "create_comments_table"},
+    .migrations = {"create_todos_table", "create_comments_table", "create_daily_stats_table"},
     .seeds = {"seed_todos"}
   );
+  task("record_daily_stats", {
+    sqlite_query({"todos_db", "record_daily_stats"})
+  }, .cron = "0 0 * * *");
     .post = {
       input({"title", m_not_empty}),
       sqlite_query({"todos_db", "create_todo"}),
+      task("record_daily_stats"),
       redirect("todos")
     },

The .cron schedule and the task("record_daily_stats") step both run the task on a task reactor, off the request reactors, so the POST returns immediately. Tasks are durable, so a crash mid-task resumes on the next boot. To hand values to a task, list them under .accepts. See Task Pipelines.

6. Modules and Events

As the app grows, split features into modules that talk through pub/sub events rather than calling each other. A module is a folder with a matching .c file (todos/todos.c) defining void todos_config(), which calls name("todos") and declares the module's resources, databases, tasks, and subscribers. Templates and SQL in that folder belong to the module. main.c composes modules by #includeing each .c and calling its _config().

This step moves todos into its own module and adds an activity module that records an entry whenever a todo is created. Move the todos assets into todos/, add the new activity/ folder, and leave home (which is also the shared layout) at the root:

.
├── todos/
│   ├── todos.c
│   ├── todos.mustache.html
│   ├── todo_detail.mustache.html
│   ├── create_todos_table.sql
│   ├── seed_todos.sql
│   ├── get_todos.sql
│   ├── get_todo.sql
│   ├── get_comments.sql
│   └── create_todo.sql
├── activity/
│   ├── activity.c
│   ├── activity.mustache.html
│   ├── create_activity_table.sql
│   ├── get_activities.sql
│   └── insert_activity.sql
├── home.mustache.html
└── main.c

main.c collapses to a thin composer:

main.c

 #include <mach.h>
-#include <sqlite.h>
+#include "todos/todos.c"
+#include "activity/activity.c"

 void mach(){
-  sqlite_config();
-
-  sqlite_database(
-    .name = "todos_db",
-    .connect = "file:todos.db?mode=rwc",
-    .migrations = {"create_todos_table", "create_comments_table", "create_daily_stats_table"},
-    .seeds = {"seed_todos"}
-  );
-
-  task("record_daily_stats", {
-    sqlite_query({"todos_db", "record_daily_stats"})
-  }, .cron = "0 0 * * *");
-
+  todos_config();
+  activity_config();
   resource("home", "/", .get = { mustache("home") });
-
-  resource("todos", "/todos", ...);
-  resource("todo", "/todos/:id", ...);
 }

The todos logic moves into the module unchanged, gaining a name(), a publish(), and an emit() step:

todos/todos.c

#include <mach.h>
#include <sqlite.h>
#include <pubsub.h>

void todos_config(){
  name("todos");
  sqlite_config();
  pubsub_config();

  sqlite_database(
    .name = "todos_db",
    .connect = "file:todos.db?mode=rwc",
    .migrations = {"create_todos_table", "create_comments_table", "create_daily_stats_table"},
    .seeds = {"seed_todos"}
  );

  publish("todo_created", .with = {"title"});

  task("record_daily_stats", {
    sqlite_query({"todos_db", "record_daily_stats"})
  }, .cron = "0 0 * * *");

  resource("todos", "/todos",
    .get = {
      sqlite_query({"todos_db", "get_todos", "todos_data"}),
      mustache("todos")
    },
    .post = {
      input({"title", m_not_empty}),
      sqlite_query({"todos_db", "create_todo"}),
      task("record_daily_stats"),
      emit("todo_created"),
      redirect("todos")
    },
    .errors = {
      {m_bad_request, {reroute("todos")}}
    }
  );
}

The activity module subscribes to the event; nothing in it references the todos module:

activity/insert_activity.sql

insert into activities(kind, ref) values('created', {{title}});

activity/activity.c

#include <mach.h>
#include <sqlite.h>
#include <pubsub.h>

void activity_config(){
  name("activity");
  sqlite_config();
  pubsub_config();

  sqlite_database(
    .name = "activity_db",
    .connect = "file:activity.db?mode=rwc",
    .migrations = {"create_activity_table"}
  );

  subscribe("todo_created", {
    sqlite_query({"activity_db", "insert_activity"})
  });

  resource("activity", "/activity",
    .get = {
      sqlite_query({"activity_db", "get_activities", "activities"}),
      mustache("activity")
    }
  );
}

When the POST calls emit("todo_created"), MACH propagates the keys named in publish(...).with (here title) to every subscriber. The activity module writes its row, with no direct link between the two modules. Events are durable: undelivered ones replay after a crash. Adding a third subscriber is a new file with its own subscribe(...); the publisher does not change. See Modules and Composition and Event Pipelines.

7. Calling APIs

fetch() calls external HTTP services like a query calls a database. JSON parses into context tables; multiple items in one fetch() run concurrently.

Show the responses on the home page:

home.mustache.html

 <html>
   <body>
     <nav><a href='{{url:home}}'>Home</a> · <a href='{{url:todos}}'>My Todos</a></nav>
-    <main>{{$body}}<h1>Welcome</h1>{{/body}}</main>
+    <main>{{$body}}
+      <h1>Welcome</h1>
+      {{#weather}}<p>{{city}}: {{precision:temp_c:0}}°C, {{conditions}}</p>{{/weather}}
+      {{#quote}}<blockquote>{{content}}, {{author}}</blockquote>{{/quote}}
+    {{/body}}</main>
   </body>
 </html>

Fetch both services concurrently before rendering:

main.c

-  resource("home", "/", .get = { mustache("home") });
+  resource("home", "/",
+    .get = {
+      fetch(
+        {"https://api.quotable.io/random", .set_key = "quote"},
+        {"https://api.weather.dev/now", .set_key = "weather"}
+      ),
+      mustache("home")
+    }
+  );

Both requests run concurrently under one fetch() call; the JSON parses into context tables the template walks with {{#quote}} and {{#weather}}. fetch() also supports other verbs, headers, request bodies, and interpolated URLs. See fetch.


Reference

Context

Pipelines read from and write to a shared, scoped key-value store that lives for the duration of one request. Every step draws inputs from context and writes outputs back to it.

Values enter the context from several places: sqlite_query() (and the other engine queries) store result tables under their set_key; fetch() stores parsed responses; input() promotes validated request parameters; session() loads the current user; and set() inside exec() writes computed values. Docker secrets exposed to the container are also available.

The context uses three scopes: input:xxx for raw request parameters, error:xxx for validation/error data, and unprefixed names for app scope (query results, validated inputs, computed values). The input() step promotes values from the input: scope to app scope.

Templates and SQL are not part of the context. They are assets, discovered automatically from *.mustache.html and *.sql files anywhere in the project and referenced by name (filename without the type suffix). There is no registration step and no #embed; saving the file is enough. Each asset belongs to a module based on where it sits in the directory tree, and a pipeline can reference assets from its own module or any ancestor module. See Modules and Composition.

Reading and writing at runtime happens through the Imperative API inside exec():

exec(^(){
  auto rows = get("todos");
  if (table_count(rows) > 5) set("is_urgent", "1");
})

App-wide values that should be present on every request can be seeded with a set() in a middleware() step:

middleware(exec(^(){ set("site_name", "MACH App"); }));

Context Scoping

Databases

Each database is registered with its engine's <engine>_database(...) call (for example sqlite_database(...)), and the engine module must be activated first with <engine>_config(). There is no .engine field; the function you call selects the engine. Migrations are forward-only and index-based: they run in array order, each applied once, with new migrations appended to the end. Seeds are idempotent and safe to re-run. Both are tracked in a mach_meta table.

Multi-tenant databases use {{interpolation}} in .connect. Connections are pooled with LRU eviction: active tenants stay warm, idle connections are reclaimed.

.name: identifier referenced by the first positional of query() steps.

.name = "todos_db"

.connect: engine-specific connection string. Supports {{interpolation}} for multi-tenancy.

.connect = "file:{{user_id}}_todo.db?mode=rwc"

.migrations: array of SQL migration entries, applied once each in order. Each entry is either an asset name (a discovered .sql file) or an inline SQL string.

.migrations = {"create_todos_table", "create_comments_table"}

.seeds: array of idempotent seed entries (asset name or inline SQL), safe to re-run on every boot.

.seeds = {"seed_todos"}

Combined:

sqlite_config();

sqlite_database(
  .name = "blog_db",
  .connect = "file:{{user_id}}_blog.db?mode=rwc",
  .migrations = {"create_blogs_table", "create_comments_table"},
  .seeds = {"seed_blogs"}
);

Engine activation / query / register: sqlite_config() + sqlite_query() + sqlite_database(), and likewise postgres_*, mysql_*, redis_*, duckdb_*.

Database Multi-Tenancy

Resource Pipelines

MACH is resource-based, not route-based. Each resource(...) call defines a named URL endpoint with HTTP verb pipelines. Resources are identified by name. {{url:name}}, redirect(), and reroute() all take only the resource name; any :params in the URL pattern are read from the current scope by matching key names. Path specificity is automatic: exact matches (/todos/active) take priority over parameterized matches (/todos/:id) regardless of definition order.

Clients select a verb via the request method, or by passing http_method as a query/form parameter. This lets HTML forms (limited to GET/POST) reach any verb, and gives SSE a connection path: /todos?http_method=sse.

Resource name (pos): resource identifier used by {{url:name}}, redirect(), and reroute().

resource("todos", "/todos", .get = { ... });

URL pattern (pos): URL pattern. Supports :params.

resource("todo", "/todos/:id", .get = { ... });

.all: shared steps that run before every verb pipeline on the resource.

resource("todo", "/todos/:id",
  .all = { input({"id", m_integer, "must be a number"}) },
  .get = { ... },
  .delete = { ... }
);

.mime: default response content type for the resource.

resource("feed", "/feed.json", .mime = m_json, .get = { ... });

.get .post .put .patch .delete: verb pipelines: ordered arrays of steps that transform a request into a response.

resource("todos", "/todos",
  .get = { sqlite_query({"db", "get_todos", "todos_data"}), mustache("todos") },
  .post = { input({"title", m_not_empty}), redirect("todos") }
);

.sse: persistent SSE channel. First positional is the channel name (supports {{interpolation}}); any remaining steps run on connect.

resource("todos", "/todos",
  .sse = {"todos:{{user_id}}",
    sqlite_query({"db", "get_todos", "todos_data"}),
    sse(.event = "initial", .data = {"{{todos_data}}"})
  }
);

.errors / .repairs: resource-scoped error and repair pipelines. See Error and Repair Pipelines.

resource("todos", "/todos",
  .post = { ... },
  .errors = {{m_bad_request, {mustache("form")}}}
);

Combined:

resource("todo", "/todos/:id",
  .all = { input({"id", m_positive, "must be a number"}) },
  .get = { sqlite_query({"todos_db", "get_todo", "todo", .must_exist = true}),
              mustache("todo") },
  .patch = { input({"title", m_not_empty, "required"}),
              sqlite_query({"todos_db", "update_todo"}),
              redirect("todo") },
  .delete = { sqlite_query({"todos_db", "delete_todo"}),
              redirect("todos") },
  .sse = {"todo/{{id}}", sse(.event = "ready") },
  .errors = {{m_not_found, {mustache("404")}}}
);

MIME types (for .mime): m_html, m_txt, m_sse, m_json, m_js

Template Helpers

Templates are Mustache. MACH supports the full Mustache base spec with one exception: dot notation. Use a section instead. {{a.b}} does not work; {{#a}}{{b}}{{/a}} does.

Supported base-spec features:

  • Interpolation: {{name}} (HTML-escaped), {{{name}}} or {{&name}} (unescaped).
  • Sections: {{#name}}...{{/name}} renders when truthy and iterates over arrays.
  • Inverted sections: {{^name}}...{{/name}} renders when falsy or empty.
  • Comments: {{! ignored }}.
  • Set delimiters: {{=<% %>=}}.
  • Partials: {{>name}} inlines the asset named name, rendered against the current scope.
  • Layout inheritance: {{<parent}}{{$block}}override{{/block}}{{/parent}} renders parent with each {{$block}}default{{/block}} block in the parent replaced by the override here. Any asset that declares blocks can be a parent.

No Mustache extensions are supported. There are no lambdas, no {{#if}} / {{else}}, no {{#each}}. Branching and iteration come from sections; counts and conditions are computed in SQL or exec() and rendered as plain context values.

On top of base Mustache, templates and other interpolated strings support built-in helpers with {{helper:args}} syntax. Arguments are positional and colon-separated; each can be a literal or a context key.

{{precision:field:N}}: format a numeric value with N decimal places.

mustache(.body = "<p>Total: ${{precision:total:2}}</p>")

{{input:field}}: raw, unvalidated request parameter from the input scope. Typically used to repopulate form fields after a validation error.

mustache(.body = "<input name='title' value='{{input:title}}'>")

{{error:field}}: truthy when field has an error. Used as a Mustache section to conditionally render markup.

mustache(.body = "{{#error:title}}<span class='error'>invalid</span>{{/error:title}}")

{{error_message:field}}: human-readable message for a field error, from input()'s message or from error_set().

mustache(.body = "<span>{{error_message:title}}</span>")

{{error_code:field}}: HTTP status code associated with a field error (e.g. 400, 404).

mustache(.body = "<p>Code: {{error_code:title}}</p>")

{{url:name}}: resolve a resource name to its URL. :params in the URL pattern are read from the current scope by name; no positional args.

mustache(.body =
  "<a href='{{url:todos}}'>All</a>" // /todos
  "{{#todos_data}}<a href='{{url:todo}}'>{{title}}</a>{{/todos_data}}" // /todos/{{id}} per row
  "{{#todo}}<a href='{{url:todo}}'>{{title}}</a>{{/todo}}" // /todos/{{id}} from a single record
)

{{asset:filename}}: resolve a file in public/ to a cache-busted URL (content checksum + immutable cache headers). See Static Files.

mustache(.body = "<link rel='stylesheet' href='{{asset:styles.css}}'>")

{{csrf:token}}: emit a CSRF token, for use in URL query strings. Generates a random hash, sets it on an httponly/secure/samesite cookie, and outputs the same value inline.

mustache(.body = "<a href='{{url:logout}}?csrf={{csrf:token}}'>Log out</a>")

{{csrf:input}}: emit a hidden <input> carrying a CSRF token, for use inside a <form>. Same cookie-setting behavior as {{csrf:token}}.

mustache(.body = "<form>{{csrf:input}}<input name='title'><button>Add</button></form>")

CSRF verification is automatic. MACH checks that the incoming token (from the form field or query parameter) matches the value stored in the CSRF cookie and rejects mismatches with a 403. The cookie is httponly, secure, and samesite, so nothing beyond emitting {{csrf:token}} or {{csrf:input}} in the rendered response is required.


Pipeline Steps

Steps are the units of work in a pipeline. Each receives the current context, acts on it, and passes control to the next. All steps accept .if_context and .unless_context for conditional execution, and .table_key for concurrent fan-out across rows of a context table (see Iteration).

Concurrency comes from multiple items inside one step, not from multiple steps in sequence. sqlite_query({...}, {...}) runs both queries concurrently; two back-to-back sqlite_query({...}) steps run serially. The same applies to fetch() and to .table_key iteration.

Request Pipeline Flow

input

Checks request parameters (query string, form body, URL params) against regex patterns. On success, each value is promoted from input:name to app scope. On failure, errors land in error:name and a 400 Bad Request triggers the nearest error/repair pipeline. All validations in one call complete before the error fires, so all errors are available together for form re-rendering.

Built-in regex macros are defined in mach.h; define your own the same way: #define m_zipcode "^\\d{5}$". For checks that don't fit a regex (uniqueness, cross-field rules, async lookups), follow input() with an exec() step that reads the value from context and calls error_set() on failure.

.param_key (pos): name of the parameter to validate.

input({"title", "^\\S+$", "required"})

.matches (pos): regex pattern, or a built-in validator macro.

input({"email", m_email, "bad email"})

.message (pos): human-readable error shown via {{error_message:name}}.

input({"age", m_integer, "must be a number"})

.optional: skip validation when the parameter is absent.

input({"filter", "^(active|done)$", .optional = true})

.fallback: default value injected when the parameter is absent.

input({"page", m_integer, .fallback = "1"})

Combined:

input(
  {"email", m_email, "must be a valid email"},
  {"title", m_not_empty, "cannot be empty"},
  {"page", m_integer, "must be a number", .fallback = "1"},
  {"filter", "^(active|done)$", "must be 'active' or 'done'", .optional = true},
  {"username", m_username, "must be alphanumeric"}
)

For checks beyond regex (uniqueness, cross-field rules, lookups), pair input() with a query and exec():

input({"username", m_username, "must be alphanumeric"}),
sqlite_query({"users_db", "find_username", "existing"}),
exec(^(){
  auto rows = get("existing");
  if (rows && table_count(rows) > 0)
    error_set("username", (error){m_bad_request, "already taken"});
})

Built-in validators:

  • Strings: m_not_empty, m_alpha, m_alphanumeric, m_slug, m_no_html
  • Numbers: m_integer, m_positive, m_float, m_percentage
  • Identity: m_email, m_uuid, m_username
  • Dates & times: m_date, m_time, m_datetime
  • Web: m_url, m_ipv4, m_hex_color
  • Codes: m_zipcode_us, m_phone_e164, m_cron
  • Security: m_token, m_base64
  • Boolean: m_boolean, m_yes_no, m_on_off

query

Each database engine provides its own query step: sqlite_query(), postgres_query(), mysql_query(), redis_query(), duckdb_query(). All share the same query_config shape. The first positional is the database .name; the second is the SQL asset to run; the third is the .set_key the result table is stored under (even single-row results are tables). Multiple items in a single step run concurrently. Queries use prepared statements; interpolated {{values}} are bound, not spliced. For transactions, put BEGIN/COMMIT/ROLLBACK directly in the SQL.

.db (pos): name of the database, matching a registered <engine>_database(.name = ...).

sqlite_query({"todos_db", "get_todos", "todos_data"})

.query (pos): name of a SQL asset (a discovered .sql file). Mutually exclusive with .body.

sqlite_query({"todos_db", "get_todos", "todos_data"})

.set_key (pos): context key for the result table. Optional; omit it when the result isn't needed (e.g. an insert without RETURNING).

sqlite_query({"todos_db", "create_todo"}) // no result captured
sqlite_query({"todos_db", "get_todos", "todos_data"}) // result under "todos_data"

.body: inline SQL string, as an alternative to a named asset. Supports {{interpolation}}, bound as parameters. Mutually exclusive with the .query asset name.

sqlite_query({.db = "todos_db", .set_key = "todos_data", .body = "select id, title from todos where user_id = {{user_id}};"})

.must_exist: when true, raise 404 Not Found if the query affects/returns zero rows. Default is false.

sqlite_query({"todos_db", "get_todo", "todo", .must_exist = true})

.if_context / .unless_context (per item): conditionally include or skip individual queries while running the others concurrently.

sqlite_query(
  {"db", "get_todos", "todos_data"},
  {"db", "get_urgent", "urgent", .if_context = "show_urgent"}
)

Combined:

sqlite_query(
  {"todos_db", "get_todos", "todos_data"},
  {.db = "todos_db", .set_key = "count", .body = "select count(*) as n from todos where user_id = {{user_id}};"},
  {"todos_db", "get_urgent", "urgent", .if_context = "show_urgent"}
)

join

Nests records from one context table into each matching record of another, like a SQL JOIN performed in memory. Useful when records come from separate databases or queries and need to be combined. After the step, each outer record gains a new field holding its matched inner records.

.parent_key: outer table whose records receive nested children.

.parent_key = "projects"

.field_key: field on the outer table to match against.

.field_key = "id"

.child_key: inner table whose records get nested.

.child_key = "todos"

.child_field_key: field on the inner table that points at the outer.

.child_field_key = "project_id"

.join_field_key: new field on outer records holding the matched inner records.

.join_field_key = "todos"

Combined:

join(.parent_key = "projects", .field_key = "id", .child_key = "todos", .child_field_key = "project_id", .join_field_key = "todos")

Full context example. Concurrent query → join()mustache(): fetch parent and children from separate queries, then render them as one nested structure. Blog + comments, single database:

resource("blog", "/blogs/:id",
  .get = {
    input({"id", m_integer}),

    // Fetch both concurrently: one query() call, two items
    sqlite_query(
      {.db = "blog_db", .set_key = "blog", .body = "select id, title, content from blogs where id = {{id}};"},
      {.db = "blog_db", .set_key = "comments", .body = "select id, blog_id, body from comments where blog_id = {{id}};"}
    ),

    // Nest each comment into its matching blog record
    join(.parent_key = "blog", .field_key = "id", .child_key = "comments", .child_field_key = "blog_id", .join_field_key = "comments"),

    // Enter {{#blog}} first; after join(), comments lives INSIDE each blog record
    mustache(.body =
      "<article>"
        "{{#blog}}"
          "<h1>{{title}}</h1>"
          "<div>{{content}}</div>"
          "<h2>Comments</h2>"
          "<ul>{{#comments}}<li>{{body}}</li>{{/comments}}</ul>"
        "{{/blog}}"
      "</article>"
    )
  }
);

Shape of the context at each step:

after query(): { blog: [{id, title, content}],
                 comments: [{id, blog_id, body}, ...] } // two sibling tables

after join(): { blog: [{id, title, content,
                         comments: [{id, blog_id, body}, ...]}] } // nested inside blog

fetch

Makes one or more HTTP requests and stores responses in context. JSON is parsed into tables and records (with nested tables for nested JSON); plain-text responses are stored as a string. Like query(), multiple items in a single step run concurrently, so a page can fan out to several services and join the results downstream.

.url (pos): request URL; supports {{interpolation}}.

fetch({"https://api.weather.dev/forecast?city={{city}}", .set_key = "w"})

.set_key: context key for the response.

fetch({"https://api.weather.dev/now", .set_key = "weather"})

.method: HTTP method. Defaults to m_get.

fetch({"https://api.dev/charge", .set_key = "r", .method = m_post})

.headers: array of name/value pairs.

fetch({"https://api.dev/me", .set_key = "r", .headers = {{"Authorization", "Bearer {{token}}"}}})

.json: context key serialized as the JSON request body.

fetch({"https://api.dev/charge", .set_key = "receipt", .method = m_post, .json = "order"})

.text: context key sent as the plain-text request body.

fetch({"https://api.dev/log", .set_key = "r", .method = m_post, .text = "raw_body"})

.if_context / .unless_context (per item): conditionally include or skip individual requests while running the others concurrently.

fetch(
  {"https://api.weather.dev/now", .set_key = "weather"},
  {"https://api.quotes.dev/random", .set_key = "quote", .if_context = "show_quote"}
)

Combined, single request:

fetch({"https://api.payments.dev/charge",
  .set_key = "receipt",
  .method = m_post,
  .headers = {
    {"Authorization", "Bearer {{api_key}}"},
    {"Idempotency-Key", "{{order_id}}"}
  },
  .json = "order"
})

Combined, concurrent fan-out:

fetch(
  {"https://api.weather.dev/now?city={{city}}", .set_key = "weather"},
  {"https://api.news.dev/headlines?topic={{topic}}", .set_key = "news"},
  {"https://api.quotes.dev/random", .set_key = "quote"}
)

HTTP methods (for .method): m_get, m_post, m_put, m_patch, m_delete, m_sse_method

exec and worker

exec() calls a C function or block with access to the context via the Imperative API. It is where business logic and data shaping lives: enriching query results with computed fields, aggregating across rows, transforming data between steps, setting flags for conditional downstream steps. To trigger an error/repair pipeline from inside, call error_set().

worker() takes the same configuration as exec() but is for blocking or CPU-bound work such as calling external C libraries, blocking I/O, or heavy computation. The work is dispatched to the shared thread pool, which releases the reactor; the pipeline resumes on the original reactor when the call returns. Use worker() when the body would otherwise stall a request reactor.

Block (pos): inline block, for short logic specific to this pipeline. Here, attaching each challenger's opponent id so the template can render two voting forms with the right winner/loser pairing:

exec(^(){
  auto const t = get("challengers");
  auto const p0 = table_get(t, 0);
  auto const p1 = table_get(t, 1);
  record_set(p0, "opponent_id", record_get(p1, "id"));
  record_set(p1, "opponent_id", record_get(p0, "id"));
})

.call: reference to a named C function, for logic reuse across pipelines.

exec(.call = assign_opponents)

Inside exec/worker blocks and functions, context, memory, errors, tables, and records are manipulated through the Imperative API.

emit

Triggers an internal pub/sub event. Subscribers in other modules react in their subscribe() pipelines, with no direct dependency on the emitter. See Event Pipelines.

Event name (pos): name of the event to publish.

emit("todo_created")

task

Adds a named job to the task database; the calling pipeline continues immediately. Task reactors pick up queued jobs and execute their pipelines. The task must be defined elsewhere with a task(name, { ... }) registration. See Task Pipelines.

Task name (pos): name of a defined task.

task("record_daily_stats")

sse

Pushes a Server-Sent Event. With .channel, the event is broadcast to all clients on that channel. Without .channel, the event is returned directly to the requesting client. See Resource Pipelines.

.channel (pos): channel to broadcast on; supports {{interpolation}}.

sse(.channel = "todos/{{user_id}}", .event = "new_todo", .data = {"{{todo}}"})

.event: SSE event: line value.

sse(.event = "ping")

.data: array of strings, one per SSE data: line (multi-line data).

sse(.event = "msg", .data = {"line one", "line two"})

.comment: SSE : comment line value, useful for keep-alives.

sse(.comment = "keep-alive")

Combined:

sse(
  .channel = "todos/{{user_id}}",
  .event = "todo_updated",
  .data = {"id: {{todo_id}}", "title: {{title}}"},
  .comment = "broadcast at {{timestamp}}"
)

mustache and mdm

Outputs a template using the current context. mustache() renders Mustache; mdm() renders Markdown-with-Mustache. Both take the same render_config: the positional .template names a discovered template asset, or .body supplies an inline template string.

.template (pos): asset name of a discovered template.

mustache("todos")

.body: inline template string.

mustache(.body = "<h1>{{site_name}}</h1>")

.status: HTTP response status (defaults to m_ok).

mustache("not_found", .status = m_not_found)

.mime: override the response content type.

mustache("plain", .mime = m_txt)

.json_table_key: context table to serialize as the JSON response. Sets application/json; nested tables produce nested JSON.

mustache(.json_table_key = "todos")

Markdown-with-Mustache:

mdm(.body = "# Welcome, {{user_name}}")

HTTP statuses (for .status): m_ok (200), m_created (201), m_redirect (302), m_bad_request (400), m_not_authorized (401), m_not_found (404), m_error (500)

MIME types (for .mime): m_html, m_txt, m_sse, m_json, m_js

headers and cookies

Set HTTP response headers and cookies declaratively. Both accept an array of name/value pairs; values support {{interpolation}}.

Pairs (pos): array of {name, value} entries.

headers({{"X-Request-Id", "{{request_id}}"}})
cookies({{"session", "{{session_id}}"}})

Combined:

headers({
  {"X-Request-Id", "{{request_id}}"},
  {"Cache-Control", "no-store"}
}),
cookies({
  {"session", "{{session_id}}"},
  {"theme", "{{theme}}"}
})

redirect and reroute

redirect() returns a 302 to the client, causing the browser to navigate. reroute() re-enters the router server-side, executing another resource's pipeline within the same request. Both take only the target resource name. :params in the target's URL pattern are read from the current context by matching key names; no positional args.

Resource name (pos): target resource name. Required :params are read from context by name.

redirect("todos") // 302 to /todos
redirect("todo") // 302 to /todos/{{id}}, id read from context
redirect("org_todo") // 302 to /orgs/{{org}}/todos/{{id}}, org and id read from context
reroute("todo") // run that pipeline in-process, id read from context

Required :params must be in context before the step runs. input() promotes validated parameters into context; otherwise, write them with set() in an exec().

nest

Groups multiple steps into a single composite step. Useful when applying one .if_context/.unless_context to several steps, to avoid repeating the condition on each.

.steps (pos): array of steps that run as a unit.

nest({sqlite_query({...}), emit("urgent_todo"), mustache("urgent")})

.if_context / .unless_context: condition applied to the whole group.

nest({sqlite_query({...}), emit("urgent_todo"), mustache("urgent")},
  .if_context = "is_urgent")

Imperative API

Functions called from exec()/worker() blocks and .call functions to read and write context, allocate memory, raise errors, and manipulate tables and records.

context

Read, write, and test context keys, and resolve {{interpolation}} against the current scope.

get(name): returns the value stored under name, or nullptr if absent. The returned pointer is whatever was stored: a string for scalars, a table for query and fetch results.

auto todos = get("todos");

set(name, value): writes value to name, exposing it to downstream steps and to templates.

set("is_urgent", "1");

has(name): returns true when name exists in the current scope.

if (has("user_id")) { ... }

format(fmt): returns fmt with {{name}} interpolations resolved against the current context. Same scopes and helpers as templates.

auto greeting = format("Hello, {{user_name}}");

Combined:

exec(^(){
  auto rows = get("todos");
  if (table_count(rows) > 5) {
    set("is_urgent", "1");
    set("banner", format("{{user_name}} has more than 5 open todos"));
  }
})

memory

Pipeline-arena allocation and deferred cleanup of foreign pointers. Both clear when the request completes.

allocate(bytes): returns a buffer from the pipeline arena. Reclaimed automatically on request completion.

auto buf = allocate(256);

defer_free(ptr): schedules free() for a pointer returned by an external library. Runs when the arena is released.

auto out = third_party_alloc(256);
defer_free(out);

Combined:

worker(^(){
  auto url = allocate(512);
  build_signed_url(url, 512, get("path"));
  set("signed_url", url);

  auto raw = third_party_render_md(get("markdown"));
  defer_free(raw);
  set("html", raw);
})

errors

Raise field-scoped errors from inside exec() to trigger error/repair pipelines. The keys land in the error:name scope, visible to templates as {{error:name}}, {{error_code:name}}, and {{error_message:name}}.

error_set(name, err): associates an error with name and triggers the nearest error or repair pipeline.

error_set("token", (error){ m_bad_request, "token has expired" });

error_get(name): returns the error previously set on name.

auto e = error_get("token");

error_has(name): returns true when name has an error.

if (error_has("token")) { ... }

Combined:

exec(^(){
  auto token = get("token");
  if (!token || strlen(token) < 16) {
    error_set("token", (error){
      m_bad_request,
      "token must be at least 16 characters"
    });
  }
})

tables

Tables are ordered collections of records, the shape query() produces and fetch() parses JSON into. Use these to build derived results.

table_new(): returns an empty table in the pipeline arena.

auto t = table_new();

table_count(t): number of records in t.

auto n = table_count(get("todos"));

table_get(t, i): record at index i, or nullptr if out of range.

auto first = table_get(get("todos"), 0);

table_add(t, r): appends r to t.

table_add(t, record_new());

table_remove(t, r): removes record r from t.

table_remove(t, r);

table_remove_at(t, i): removes the record at index i.

table_remove_at(t, 0);

Combined:

exec(^(){
  auto source = get("raw_users");
  auto active = table_new();
  for (int i = 0; i < table_count(source); i++) {
    auto u = table_get(source, i);
    auto status = record_get(u, "status");
    if (status && strcmp(status, "active") == 0) {
      table_add(active, u);
    }
  }
  set("active_users", active);
})

records

Records are name-value bags, the shape of one row from query() or one object from fetch(). All values are strings; see Everything is a String.

record_new(): returns an empty record in the pipeline arena.

auto r = record_new();

record_get(r, name): string value of name, or nullptr if absent.

auto title = record_get(r, "title");

record_set(r, name, value): writes value to name on r.

record_set(r, "title", "New title");

record_remove(r, name): removes name from r.

record_remove(r, "draft");

Combined:

exec(^(){
  auto todos = get("todos");
  for (int i = 0; i < table_count(todos); i++) {
    auto t = table_get(todos, i);
    auto title = record_get(t, "title");
    if (title && strlen(title) > 40) {
      record_set(t, "is_long", "1");
    }
  }
})

Conditionals

Every step accepts .if_context and .unless_context, which name a context variable. They work for any context value: validated inputs, query results, framework flags such as is_htmx, or flags set from exec().

.if_context: context key. Step runs only when the value is present.

mustache("fragment", .if_context = "is_htmx")

.unless_context: context key. Step runs only when the value is absent.

mustache("full_page", .unless_context = "is_htmx")

For multi-state branching, set context flags from exec(), then key downstream steps off them:

exec(.call = classify_todo),
mustache("urgent_confirmation", .if_context = "is_urgent"),
mustache("standard_confirmation", .unless_context = "is_urgent")

Iteration

.table_key names a context table to iterate over. The step runs once per row with that row's fields in scope, and all rows run concurrently, the same as multiple items in query() or fetch(). With a .set_key, results are collected into a table aligned with the input, one entry per row.

.table_key: name of a context table to iterate over.

// One request per row in `users`, all concurrent.
// Responses collected into `profiles`, aligned with `users`.
fetch({"https://api.users.dev/{{id}}", .set_key = "profiles", .table_key = "users"})

Use .table_key for per-row work that doesn't fit a single query or request, such as external APIs without bulk endpoints. For nesting tables already in context, prefer join().

Error and Repair Pipelines

When a pipeline step fails, execution halts and MACH searches for a handler bottom-up: resource, then module, then root. Errors are terminal: the handler sends a response and ends the request. Repairs are resumable: they fix the context and resume the original pipeline at the step after the failure. If no matching repair is found, resolution falls through to errors. Unhandled errors fall through to MACH's internal handler, which renders the error message as text/plain with the error code as the HTTP status, and surfaces in the TUI console and telemetry.

The error scope is shared across input() failures and error_set() calls: {{error:name}}, {{error_code:name}}, {{error_message:name}}. The raw input value remains available in input:name for re-rendering forms.

Handlers are registered two ways. At a resource, use the .errors / .repairs fields inside the resource(...) call. At module or root scope, call error(...) / repair(...) directly; the handler applies to that whole config scope.

Resource-scoped (.errors / .repairs fields):

resource("todos", "/todos",
  .post = { ... },
  .errors = {
    {m_not_found, {mustache("404")}},
    {m_bad_request, {mustache("form")}}
  },
  .repairs = {
    {m_not_authorized, {exec(.call = refresh_session_token)}}
  }
);

Module/root-scoped (error() / repair() calls):

void mach(){
  error(m_error, {mustache("5xx")});
  error(m_not_found, {mustache("404")});
  repair(m_not_authorized, {exec(.call = refresh_session_token)});
  // ... resources ...
}

Built-in error codes: m_ok (200), m_created (201), m_redirect (302), m_bad_request (400), m_not_authorized (401), m_not_found (404), m_error (500). Any integer works; the m_* constants are convenience names. Define your own for domain-specific errors, e.g. #define err_quota_exceeded 723.

Error Resolution

Event Pipelines

Internal pub/sub for cross-module communication. The publisher does not know who listens; the subscriber does not know who emits. Adding a subscriber means adding a new module with a subscribe(...) call, with no changes to the publisher. Activate the system with pubsub_config() in any module that publishes or subscribes.

Events are durable by default. When a publisher is declared anywhere in the app, MACH creates a mach_events database to track delivery. If the process crashes, undelivered events are replayed on the next boot.

publish(event, .with = {...}): declares an outbound event contract. The positional is the event name; .with lists context keys to pass along.

publish("todo_created", .with = {"user_id", "title"});

subscribe(event, { steps }): registers a subscriber pipeline keyed by event name.

subscribe("todo_created", {
  sqlite_query({"activity_db", "insert_activity"})
});

emit(event): a pipeline step that fires the event (see emit).

emit("todo_created")

.errors / .repairs (per subscriber): each subscribe(...) can declare its own handlers, resolved with the same bottom-up search as resource pipelines. See Error and Repair Pipelines.

subscribe("todo_created", {
  sqlite_query({"activity_db", "insert_activity"})
}, .errors = {{m_error, {exec(.call = log_subscriber_failure)}}});

Combined:

// todos/todos.c: publisher
void todos_config(){
  name("todos");
  sqlite_config();
  pubsub_config();

  publish("todo_created", .with = {"user_id", "title"});
  publish("todo_deleted", .with = {"user_id", "todo_id"});

  resource("todos", "/todos",
    .post = {
      input({"title", m_not_empty}),
      sqlite_query({"todos_db", "insert_todo"}),
      emit("todo_created"),
      redirect("todos")
    }
  );
}

// activity/activity.c: subscriber
void activity_config(){
  name("activity");
  sqlite_config();
  pubsub_config();

  subscribe("todo_created", {
    sqlite_query({.db = "activity_db", .body = "insert into activities(kind, user_id, ref) values('created', {{user_id}}, {{title}});"})
  });
  subscribe("todo_deleted", {
    sqlite_query({.db = "activity_db", .body = "insert into activities(kind, user_id, ref) values('deleted', {{user_id}}, {{todo_id}});"})
  });
}

Event Pub/Sub

Task Pipelines

Tasks are named pipelines that run asynchronously on task reactors. Fire-and-forget: the calling pipeline continues immediately. Defined at module or root scope. Triggered on demand with the task("name") step or on a schedule via .cron. Tasks can enqueue more tasks. Activate the system with task_config().

A task is registered with task(name, { pipeline }, ...): the name, a pipeline body, and optional .cron/.accepts. The same task("name") form with only a name is the enqueue step used inside a pipeline.

Tasks are durable: when a task is defined, MACH creates a mach_tasks database and checkpoints the context after each step. A crash mid-task resumes at the step where it left off on the next boot.

Task name (pos): task identifier, enqueued via task("name").

task("recount_todos", {
  sqlite_query({.db = "db", .body = "update users set ..."})
});

Pipeline (pos): the task's pipeline body, the second positional brace block.

task("name", { sqlite_query({...}), emit("done"), task("followup") });

.accepts: context keys to pull from the caller into the task.

task("recount_todos", {
  sqlite_query({.db = "db", .body = "update users set todo_count = ... where id = {{user_id}};"})
}, .accepts = {"user_id"});

.cron: standard cron schedule for recurring tasks (no caller required).

task("daily_digest", {
  sqlite_query({.db = "db", .body = "insert into digest_reports ..."})
}, .cron = "0 8 * * *");

.errors / .repairs (per task): each task can declare its own handlers, resolved with the same bottom-up search as resource pipelines. See Error and Repair Pipelines.

task("send_invoice", {
  fetch({"https://api.billing.dev/invoices/{{invoice_id}}", .set_key = "inv"})
}, .repairs = {{m_not_authorized, {exec(.call = refresh_billing_token)}}});

Combined:

// on-demand: enqueued via task("recount_todos")
task("recount_todos", {
  sqlite_query({.db = "todos_db", .body = "update users set todo_count = (select count(*) from todos where user_id = users.id) where id = {{user_id}};"})
}, .accepts = {"user_id"});

// recurring: runs on schedule, no caller
task("daily_digest", {
  sqlite_query({.db = "todos_db", .body = "insert into digest_reports(generated_at) values(now());"}),
  emit("digest_ready")
}, .cron = "0 8 * * *");

Modules and Composition

Every MACH app and module is a function that registers its pieces by calling into the MACH API. The root main.c must define void mach(); a module defines void <name>_config() and declares its identity with name("..."). A module owns its own resources, databases, migrations, tasks, event contracts, and middleware.

Compose by #includeing a module's .c file and calling its _config() function. Activation is idempotent: calling sqlite_config() from several modules, or a module's _config() indirectly more than once, is safe and deduplicated. This replaces listing a module in multiple .modules arrays.

Assets and module ownership. Templates and SQL can live anywhere in the directory tree. To decide which module owns an asset, MACH looks at the asset's containing folder: a folder is a module when it contains a matching <folder>.c file (so todo/ is a module when todo/todo.c exists). An asset belongs to the nearest such module folder walking up from its location; folders without a matching .c file are organizational only and pass ownership up to their parent. The root is the main module, defined by main.c. Given:

.
├── main.c # defines void mach(), the "main" module
├── layout.mustache.html # → main (root, no nearer module folder)
├── asset/
│   └── home.mustache.html # → main (asset/ has no asset.c, so it's just a folder)
└── todo/
    ├── todo.c # defines void todo_config(), the "todo" module
    ├── todos.mustache.html # → todo
    └── stuff/
        └── todo.mustache.html # → todo (stuff/ has no stuff.c, ownership rises to todo)

A pipeline can reference assets owned by its own module or by any ancestor module, never by sibling or descendant modules. So the todo module can render layout (owned by its parent main) and its own todos, but main cannot reach into todo's assets. A parent can therefore provide shared chrome while keeping module internals private.

When the root and a module both define something with the same name, resolution depends on the kind. Context variables and databases resolve top-down from the root, so the root wins. Error and repair handlers resolve bottom-up from the resource (resource → module → root), so the innermost handler wins. Shared steps participate in every request pipeline within their scope and execute bottom-up: a resource's .all steps run first, then enclosing middleware() from innermost out (module → root), then the verb pipeline. Modules don't call each other directly; they communicate through pub/sub events.

name(string): declares a module's identifier. Called inside a module's _config(); the root mach() is implicitly named.

void todos_config(){ name("todos"); /* resources, databases, ... */ }

<name>_config(): a module's registration function. Call it to compose the module (from the root or another module).

todos_config();
activity_config();
sqlite_config();
session_auth_config();

middleware(step): registers a shared step that runs on every request within the current config scope. Cross-cutting setup like session loading or tenant resolution lives here. Call once per step.

void mach(){ middleware(session()); /* modules, resources, ... */ }

error(...) / repair(...): config-scoped error and repair handlers (see Error and Repair Pipelines). Resolved bottom-up with resource and module handlers; the innermost handler wins.

Pipeline composition. A request composes shared steps from every config layer with the resource's own steps and the verb pipeline, executing bottom-up: resource .all steps first, then enclosing middleware() from innermost out (module → root), then the verb pipeline.

// main.c: root config
#include <mach.h>
#include <session_auth.h>
#include "todos/todos.c"

void mach(){
  todos_config();
  session_auth_config();

  middleware(session());

  resource("home", "/", .get = {mustache("home")});
}

// todos/todos.c: resources require login
#include <mach.h>
#include <sqlite.h>
#include <session_auth.h>

void todos_config(){
  name("todos");
  sqlite_config();
  session_auth_config();

  resource("todos", "/todos",
    .all = {logged_in()},
    .get = { sqlite_query({"todos_db", "get_todos", "todos_data"}), mustache("todos") },
    .post = { input({"title", m_not_empty}), sqlite_query({"todos_db", "create_todo"}), redirect("todos") }
  );

  resource("todo", "/todos/:id",
    .all = {
      logged_in(),
      input({"id", m_positive})
    },
    .delete = { sqlite_query({"todos_db", "delete_todo", .must_exist = true}), redirect("todos") }
  );
}

For GET /todos/5 the executed order is: logged_in(), input({"id", ...}) (resource .all), session() (root middleware), then the verb pipeline sqlite_query({"get_todo", ..., .must_exist = true}), mustache("todo").

Complete module file. A module's _config() registers the same kinds of things the root does (resources, databases, tasks, subscribers), plus a name(). A blogs/blogs.c:

#include <mach.h>
#include <sqlite.h>

void blogs_config(){
  name("blogs");
  sqlite_config();

  sqlite_database(
    .name = "blog_db",
    .connect = "file:blogs.db?mode=rwc",
    .migrations = {"create_blogs_table", "create_comments_table"}
  );

  resource("blog", "/blogs/:id",
    .get = { /* input → query → join → render; see `join` worked example */ }
  );
}

Bring the module into scope by #includeing its .c file from main.c, then call its _config():

// main.c
#include <mach.h>
#include "blogs/blogs.c"

void mach(){ blogs_config(); }

A typical project layout:

├── todos/ # todos module (folder + matching .c)
│   ├── todos.c # void todos_config() { ... }
│   ├── todos.mustache.html # → todos
│   ├── create_todos_table.sql # → todos
│   └── get_todos.sql # → todos
├── activity/ # activity module
│   └── activity.c
├── home.mustache.html # → main (also the shared layout)
├── public/ # static files, served directly
│   └── favicon.png
└── main.c # void mach(), the main module, composes the rest

Bundled modules (activate by calling their _config()): sqlite_config, postgres_config, mysql_config, redis_config, duckdb_config, htmx_config, datastar_config, tailwind_config, session_auth_config, pubsub_config, task_config. See Module Reference for what each provides.

App Composition Tree Middleware Scoping


Module Reference

Bundled modules. Activate each by calling its <name>_config() from the root or any module that uses it; activation is idempotent.

htmx

Activate with htmx_config(). Serves the htmx runtime and exposes it as the {{>htmx}} partial, and sets the is_htmx context flag on requests that carry the HX-Request header. Pair the flag with .if_context/.unless_context to return a fragment to htmx and a full page to a direct visit, or use hx-boost to upgrade ordinary links and forms into AJAX swaps.

#include <mach.h>
#include <htmx.h>

void mach(){
  htmx_config();

  resource("todos", "/todos",
    .get = {
      sqlite_query({"todos_db", "get_todos", "todos_data"}),
      mustache("todos_fragment", .if_context = "is_htmx"),
      mustache("todos_page", .unless_context = "is_htmx")
    }
  );
}

Include the runtime once in the page <head>:

<head>{{>htmx}}</head>
<body hx-boost='true'>...</body>

datastar

Activate with datastar_config(). Serves the Datastar runtime as the {{>datastar}} partial and provides the datastar_sse() step for pushing reactive fragment and signal patches over an SSE channel. A page opens an SSE connection (a resource .sse channel); pipelines elsewhere push patches to that channel, and Datastar applies them in the DOM.

datastar_sse() renders a template and patches it into the page by CSS selector. The first positional is the channel (supports {{interpolation}}).

.channel (pos): channel to push to.

datastar_sse("todos:{{user_id}}", .target = "#todo-list", .mode = mode_append, .elements = {"todo_row"})

.target: CSS selector for the element to patch; supports {{interpolation}}.

.target = "#todo-{{id}}"

.mode: how the rendered fragment is applied to the target (a datastar_mode).

.mode = mode_replace

.elements: a render_config (template asset or .body) producing the fragment to patch in. Not required for mode_remove.

.elements = {"todo_row"}

.signals: context key holding signal state to merge into the client store.

.signals = "ui_state"

.js: JavaScript to execute on the client.

.js = "window.scrollTo(0, document.body.scrollHeight)"

Patch modes (datastar_mode): mode_outer, mode_inner, mode_replace, mode_prepend, mode_append, mode_before, mode_after, mode_remove.

Worked example: a POST inserts a row, returns it with RETURNING, and appends it to every connected client's list.

resource("todos", "/todos",
  // Each browser opens this channel and listens for patches.
  .sse = {"todos:{{user_id}}"},

  .post = {
    input({"title", m_not_empty}),
    // RETURNING gives the new row back; capture it under "todo".
    sqlite_query({.db = "todos_db", .set_key = "todo", .must_exist = true,
      .body = "insert into todos(user_id, title) values({{user_id}}, {{title}}) returning id, title;"}),
    // Patch the new row into the list for everyone on the channel.
    datastar_sse("todos:{{user_id}}",
      .target = "#todo-list",
      .mode = mode_append,
      .elements = {"todo_row"}
    )
  }
);

Removing an element needs only a selector:

datastar_sse("todos:{{user_id}}", .target = "#todo-{{id}}", .mode = mode_remove)

Like htmx, Datastar sets a context flag on requests it originates, usable with .if_context. Include the runtime once: <head>{{>datastar}}</head>.

SSE + Datastar Flow

tailwind

Activate with tailwind_config(). Compiles Tailwind utility classes used across the project's templates and serves the stylesheet as the {{>tailwind}} partial. Use Tailwind utility classes directly in templates; no build step or config file is required.

<head>{{>tailwind}}</head>
<body class='bg-gray-950 text-white min-h-screen'>
  <h1 class='text-3xl font-bold text-center mb-8'>Vote for which is roundest</h1>
</body>

session_auth

Activate with session_auth_config(). Provides cookie-based authentication as pipeline steps. session() loads the current user record (and user_id) into context from the session cookie; run it as middleware() so every pipeline can see who is signed in. logged_in() guards a resource, redirecting anonymous visitors to the login page. login(), logout(), and signup() perform the corresponding actions. The login page template is the asset named login.

session(): loads the current user into context from the session cookie. Use as middleware.

middleware(session());

logged_in(): requires an authenticated session; redirects to login otherwise. Use in a resource .all.

resource("todos", "/todos", .all = {logged_in()}, .get = { ... });

login() / logout() / signup(): authentication actions for the corresponding verb pipelines.

resource("login", "/login",
  .get = { mustache("login") },
  .post = { login() }
);
resource("logout", "/logout", .post = { logout() });
resource("signup", "/signup",
  .get = { mustache("signup") },
  .post = { signup() }
);

Combined: load the session globally, gate a module's resources, and read user fields in templates.

// main.c
#include <mach.h>
#include <session_auth.h>
#include "todos/todos.c"

void mach(){
  session_auth_config();
  todos_config();

  middleware(session());

  resource("login", "/login", .get = {mustache("login")}, .post = {login()});
  resource("logout", "/logout", .post = {logout()});
}
<!-- once a session() has loaded the user, templates can read it -->
{{#user}}<span>Hi, {{short_name}}</span>{{/user}}

Database engines

Each engine is its own module exposing three calls: <engine>_config() to activate, <engine>_database(...) to register a database, and <engine>_query({...}) as a pipeline step. They share the database_config and query_config shapes documented in Databases and query; only the .connect string is engine-specific.

sqlite_config(); sqlite_database(.name = "...", .connect = "file:app.db?mode=rwc", ...); sqlite_query({...});
postgres_config(); postgres_database(.name = "...", .connect = "postgres://...", ...); postgres_query({...});
mysql_config(); mysql_database(.name = "...", .connect = "mysql://...", ...); mysql_query({...});
redis_config(); redis_database(.name = "...", .connect = "redis://...", ...); redis_query({...});
duckdb_config(); duckdb_database(.name = "...", .connect = "duckdb:analytics.db", ...); duckdb_query({...});

Static Files

Files placed in public/ are served directly by the framework. Reference them in templates with {{asset:filename}}, which resolves to a content-checksummed, cache-busted URL with immutable cache headers, so updates invalidate caches automatically while unchanged files cache forever.

public/
├── favicon.png
├── styles.css
└── logo.svg
<link rel='icon' href='{{asset:favicon.png}}'>
<link rel='stylesheet' href='{{asset:styles.css}}'>
<img src='{{asset:logo.svg}}' alt='Logo'>

This differs from templates and SQL, which are assets discovered from *.mustache.html and *.sql files anywhere in the project and referenced by name within pipelines (see Modules and Composition for how assets are resolved to modules). public/ holds opaque files served to the browser.

External Dependencies

Drop third-party C libraries into a /vendor directory; MACH compiles and links them with the app. Call into them from exec()/worker() steps. Memory returned by a library that must be freed manually is registered with defer_free() so it is reclaimed when the request completes (see memory).

vendor/
└── cmark/
    ├── cmark.c
    └── cmark.h
#include <mach.h>
#include "vendor/cmark/cmark.h"

void render_markdown(){
  worker(^(){
    auto md = get("markdown");
    auto html = cmark_markdown_to_html(md, strlen(md), 0);
    defer_free(html); // library-owned pointer
    set("html_content", html);
  });
}

For dependencies that aren't plain source (system packages, build tooling), provide a custom Dockerfile. MACH builds from it instead of the default image, so anything installable in the container is available at compile and run time.


Architecture

Data-Oriented Pipelines

The mach() function runs once at boot. Its registration calls (resource(), sqlite_database(), task(), middleware(), module _config() calls, and so on) are processed into an execution graph with precompiled pipelines, queries, and templates. Each incoming request then executes its matching pipeline as a sequence of pre-warmed steps.

Boot-Time Compilation

Multi-Reactor Architecture

MACH runs two types of reactors backed by a shared thread pool. The request/task/cpu ratio can be set in compose.yml.

  • Request reactors handle HTTP traffic; each gets its own dedicated CPU core and event loop.
  • Task reactors handle background work; each gets its own dedicated core, monitors the task database for pending and incomplete tasks, and processes cron schedules.
  • Shared thread pool handles CPU-bound and blocking I/O work on the remaining cores.

When a pipeline executes a worker() step, the work is dispatched to the shared thread pool, which releases the reactor; when the call completes, the pipeline resumes on the original reactor. (An exec() step runs inline on the reactor for short, non-blocking logic.) The task() step adds jobs to the task database, where they are picked up by task reactors. Tasks can call task() themselves to enqueue additional work.

Application code does not manage threads, mutexes, or locks. The architecture isolates request state to the pipeline's context.

Multi-Reactor Architecture

Safe by Default

MACH prevents common C and web vulnerabilities at the framework level.

Memory Safety

Each reactor maintains a pool of arena allocators. When a request arrives, the pipeline is assigned an arena, and all allocations draw from it. When the pipeline completes, the arena is cleared and returned to the pool. Application code does not call malloc or free (it uses allocate() and defer_free() from the Imperative API when it needs raw buffers), which avoids leaks, double-frees, and use-after-free.

All framework data structures (tables, records, strings) enforce bounds checking. Out-of-bounds reads and missing context values return nullptr rather than faulting (table_get past the end, get/record_get of an absent key). Pipelines that exceed their memory limit (default 5MB, configurable in compose.yml) abort with a 500, mitigating OOM denial-of-service.

SQL Injection Prevention

Interpolations such as {{user_id}} inside a query's SQL (whether a named .query asset or inline .body) are bound as parameters in prepared statements, preventing SQL injection at the framework level.

XSS Prevention

The mustache() and mdm() steps auto-escape context values, so malicious input is rendered as text. Raw HTML requires explicit opt-in via Mustache's standard unescape syntax: {{{field}}} or {{&field}}.

CSRF Prevention

State-changing requests are verified against a per-session CSRF token. Emit the token with {{csrf:input}} (a hidden form field) or {{csrf:token}} (a value for query strings); MACH sets it on an httponly, secure, samesite cookie and rejects requests whose token does not match. See Template Helpers.

String Interpolation

Any string (SQL queries, URLs, connection strings, templates) can reference values from the context with {{context_key}}. The same scopes described in Context apply everywhere interpolation is used.


Tooling

Development Environment

Built-in TUI editor with HMR, LSP support, integrated source control, and a topology-aware AI assistant. The AI uses the app_info command to inspect the full application topology (routes, pipelines, database schemas, event contracts, and module boundaries), so it reasons about the application's actual execution graph rather than the source text alone.

Introspection

app_info # view topology
app_info resources # list all resources
app_info pipelines # inspect pipelines
app_info events # view pub/sub map
app_info databases # inspect schemas

Testing

Built-in test runners for unit and end-to-end testing; no external framework setup required.

unit_tests # fast, criterion-based tests
e2e_tests # playwright-powered browser tests

Debugging

Built-in debugging with pipeline-aware commands. Halt on individual pipeline steps, step through execution, and inspect the full pipeline context including nested tables and records.

app_debug # interactive debugger in the TUI

Deployment

MACH deploys as a standard Docker container. It does not terminate TLS; production deployments place MACH behind a reverse proxy or load balancer (Nginx, Caddy, AWS ALB) to handle HTTPS.

app_build # outputs a minimal production Docker image

Observability

Each pipeline step emits OpenTelemetry spans. Logs, traces, errors, and auto-profiling are visualized on the telemetry server at port 4000. No manual instrumentation required.

Project Management

MACH ships with integrated project infrastructure: source control, issue tracking, wiki, forum, and a project website.

Built With

C23 Language standard
Docker Development environment, production images, stack orchestration
libmicrohttpd / libuv HTTP server, event loops, async I/O, file watching, shared thread pool
Mustach Templating and string interpolation
Jansson JSON parsing and generation
curl HTTP client for fetch steps
Fossil Source control, wiki, forum, issue tracker, project site
Fresh TUI editor
clangd Language server
LLDB Debugger
Criterion Unit testing
Playwright End-to-end testing
SigNoz + OpenTelemetry APM, traces, logs, errors, dashboards
Open Code AI assistant with custom agent and skill files

License

MACH is licensed under the LGPL.

Description
examples for mach web framework, wip
Readme 5 MiB
Languages
HTML 55.4%
C 44.6%