diff --git a/.agents/README.md b/.agents/README.md
index b7c990d..4ab989a 100644
--- a/.agents/README.md
+++ b/.agents/README.md
@@ -23,6 +23,8 @@ Each skill teaches domain-specific workflows for Fusion (Rust core + Python / No
| `fusion-release` | Version bumps, manifests, publish prep |
| `fusion-testing` | Running checks; investigating failed tests |
| `fusion-cache` | Application cache (moka default; Redis later) |
+| `fusion-background-tasks` | Tokio spawn / cancel / status / snapshot |
+| `fusion-template-forms` | Template `form` / `ok` / `fail` + SPA `data-fusion-form` |
## Always-on rules
diff --git a/.agents/skills/fusion-bindings-parity/SKILL.md b/.agents/skills/fusion-bindings-parity/SKILL.md
index f536f08..1416d40 100644
--- a/.agents/skills/fusion-bindings-parity/SKILL.md
+++ b/.agents/skills/fusion-bindings-parity/SKILL.md
@@ -52,6 +52,7 @@ New public surface → show usage in **Python + Node + C#**. Prefer the same bas
| OpenAPI / Swagger | `app.py` + `api_types.rs` | `buildOpenApi` in `index.js` | `Swagger.cs` |
| Version navbar | per-version OpenAPI routes | same | same |
| Template routes | omit from OpenAPI | omit | omit |
+| Template forms | `form` / `ok` / `fail` + `data-fusion-form` | same | `Form` / `Ok` / `Fail` |
## Verification commands
diff --git a/.agents/skills/fusion-cli/SKILL.md b/.agents/skills/fusion-cli/SKILL.md
index d8b7dff..01a4024 100644
--- a/.agents/skills/fusion-cli/SKILL.md
+++ b/.agents/skills/fusion-cli/SKILL.md
@@ -118,6 +118,7 @@ C# (`asp-core`): `main.cs`, `*.csproj` (`net10.0`), `[Route]` / `[HttpGet]`.
### What the starter demonstrates
- `FusionBaseTemplate` at `/` (Tera templates; **not** listed in Swagger).
+- HTML forms: `form` / `ok` / `fail` + optional `data-fusion-form` (see skill `fusion-template-forms`).
- Welcome UI via built-in components: `fusion.badge`, `fusion.button`, `fusion.card`, `fusion.table` (optional `page_size={10}` for client-side row pagination; styles: `{% include "fusion/components.css" %}`).
- `FusionBaseApi` at `api/[module]` with `version="v1"` → `/v1/api/product/…`.
- Convention verbs (`get` / `post` / …) plus one custom slot (`http_get` / `httpGet` / `[HttpGet]` with `[action]`).
diff --git a/.agents/skills/fusion-template-forms/SKILL.md b/.agents/skills/fusion-template-forms/SKILL.md
new file mode 100644
index 0000000..0fc6d78
--- /dev/null
+++ b/.agents/skills/fusion-template-forms/SKILL.md
@@ -0,0 +1,54 @@
+---
+name: fusion-template-forms
+description: >-
+ Documents FusionBaseTemplate form helpers (form, ok, fail), context vs get vs
+ post, and SPA-friendly data-fusion-form + fusion/form.js. Use when building
+ HTML forms that POST to the same page.
+---
+
+# Template forms (SPA-friendly)
+
+## Mental model
+
+| Method | Role |
+|--------|------|
+| `context()` | Template **data** (title, fields). Not an HTTP verb. |
+| `get()` | **HTTP GET** — renders `context()` as HTML (or JSON if `Accept: application/json`). Rarely override. |
+| `post()` | **HTTP POST** — read `form`, validate with normal `if`, return `ok` / `fail`. |
+
+## API
+
+| Python | Node | C# |
+|--------|------|-----|
+| `self.form` | `this.form` | `Form` |
+| `self.fail(errors, message=..., **fields)` | `this.fail(errors, { message, ...fields })` | `Fail(errors, message, fields)` |
+| `self.ok(message=..., **fields)` | `this.ok({ message, ...fields })` | `Ok(message, fields)` |
+
+- JSON clients (`Accept: application/json` or SPA fetch) get `{ ok, message, errors, fields }`.
+- HTML clients re-render the **same** template with errors/fields (no separate “success page” required).
+
+## SPA markup
+
+```html
+
+
+```
+
+Built-in script lives at `fusion/form.js` (embedded in fusion-core). Without JS, classic POST still works via `ok`/`fail` HTML path.
+
+## Example
+
+```python
+def post(self):
+ form = self.form
+ if not form.get("phone"):
+ return self.fail({"phone": "required"}, **form)
+ return self.ok(message="Saved.", **form)
+```
+
+See `examples/template_form.{py,mjs,cs}`.
diff --git a/bindings/csharp/FusionFramework/FusionBaseTemplate.cs b/bindings/csharp/FusionFramework/FusionBaseTemplate.cs
index ad25542..3f1145a 100644
--- a/bindings/csharp/FusionFramework/FusionBaseTemplate.cs
+++ b/bindings/csharp/FusionFramework/FusionBaseTemplate.cs
@@ -10,13 +10,71 @@ public abstract class FusionBaseTemplate : FusionBaseApi
public static string TemplateAddress { get; set; } = "";
public static string TemplatesDir { get; set; } = "";
- /// Sync template variables (override in subclasses).
+ ///
+ /// Template variables (not an HTTP verb). Override in subclasses.
+ /// renders this as HTML; POST handlers use / / .
+ ///
public virtual Dictionary Context() => new();
/// Async template variables; default wraps .
public virtual Task> ContextAsync() =>
Task.FromResult(Context());
+ /// Parsed POST body (urlencoded or JSON) as flat string fields.
+ public Dictionary Form => ParseFormBody(Body, ContentType());
+
+ /// Parse urlencoded or JSON body into flat string fields.
+ public static Dictionary ParseFormBody(string? body, string? contentType)
+ {
+ var raw = body ?? "";
+ var ct = (contentType ?? "").ToLowerInvariant();
+ var outDict = new Dictionary(StringComparer.Ordinal);
+ if (ct.Contains("application/json", StringComparison.Ordinal)
+ || (raw.TrimStart().StartsWith('{') && !ct.Contains("urlencoded", StringComparison.Ordinal)))
+ {
+ try
+ {
+ var node = string.IsNullOrWhiteSpace(raw) ? null : JsonNode.Parse(raw);
+ if (node is JsonObject obj)
+ {
+ foreach (var kv in obj)
+ outDict[kv.Key] = kv.Value is null || kv.Value.GetValueKind() == JsonValueKind.Null
+ ? ""
+ : kv.Value.ToString() ?? "";
+ }
+ }
+ catch (JsonException)
+ {
+ // ignore invalid JSON
+ }
+ return outDict;
+ }
+
+ if (string.IsNullOrEmpty(raw))
+ return outDict;
+
+ foreach (var pair in raw.Split('&', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var parts = pair.Split('=', 2);
+ var key = Uri.UnescapeDataString(parts[0].Replace('+', ' '));
+ var value = parts.Length > 1
+ ? Uri.UnescapeDataString(parts[1].Replace('+', ' '))
+ : "";
+ outDict[key] = value;
+ }
+ return outDict;
+ }
+
+ string? ContentType()
+ {
+ foreach (var kv in Request.Headers)
+ {
+ if (string.Equals(kv.Key, "Content-Type", StringComparison.OrdinalIgnoreCase))
+ return kv.Value;
+ }
+ return null;
+ }
+
///
/// Default GET — HTML or JSON context. Uses so
/// subclasses can override that for DB/API-backed pages (Python async context parity).
@@ -42,6 +100,103 @@ object FinishGet(Dictionary ctx)
return HtmlResponse(ctx);
}
+ /// Validation failure — JSON for SPA fetch, else same template with errors.
+ public object Fail(
+ IDictionary? errors = null,
+ string? message = null,
+ IDictionary? fields = null)
+ {
+ var err = errors?.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal)
+ ?? new Dictionary(StringComparer.Ordinal);
+ var flat = fields?.ToDictionary(
+ kv => kv.Key,
+ kv => kv.Value ?? "",
+ StringComparer.Ordinal)
+ ?? new Dictionary(StringComparer.Ordinal);
+ var msg = message ?? "Validation failed";
+ if (WantsJson())
+ {
+ return Response(new Dictionary
+ {
+ ["ok"] = false,
+ ["message"] = msg,
+ ["errors"] = err,
+ ["fields"] = flat,
+ }, 400);
+ }
+ return FormHtmlResult(ok: false, message: msg, errors: err, fields: flat, status: 400);
+ }
+
+ /// Success — JSON for SPA fetch, else same template with ok=true.
+ public object Ok(string? message = null, IDictionary? fields = null)
+ {
+ var flat = fields?.ToDictionary(
+ kv => kv.Key,
+ kv => kv.Value ?? "",
+ StringComparer.Ordinal)
+ ?? new Dictionary(StringComparer.Ordinal);
+ var msg = message ?? "OK";
+ if (WantsJson())
+ {
+ return Response(new Dictionary
+ {
+ ["ok"] = true,
+ ["message"] = msg,
+ ["errors"] = new Dictionary(),
+ ["fields"] = flat,
+ }, 200);
+ }
+ return FormHtmlResult(
+ ok: true,
+ message: msg,
+ errors: new Dictionary(),
+ fields: flat,
+ status: 200);
+ }
+
+ object FormHtmlResult(
+ bool ok,
+ string message,
+ IDictionary errors,
+ IDictionary fields,
+ int status)
+ {
+ var task = ContextAsync();
+ if (!task.IsCompletedSuccessfully)
+ return FormHtmlResultAsync(task, ok, message, errors, fields, status);
+ return FinishFormHtml(task.Result, ok, message, errors, fields, status);
+ }
+
+ async Task