zigts-expert - Skill + Compiler-in-the-Loop
Architecture
1. Read this SKILL.md (language expertise)
2. Write zigts code using the rules below
3. Run: zigts check --json handler.ts
4. Read structured JSON from stdout
5. Fix errors using suggestion fields
6. Repeat until success
No MCP server. No protocol. No config wiring. Just a binary and a skill.
When to Use
Trigger when: writing .ts files for zigttp handlers, compiling with zigts, debugging zigts compiler errors, or asking about the zigts language subset.
CLI Reference
# Verify handler and show proof card (human-readable)
zigts check handler.ts
# Verify with structured JSON output (use this in the loop)
zigts check --json handler.ts
# Compile handler to bytecode
zigts compile handler.ts output.zig
# List what's allowed and what's blocked
zigts features
zigts features --json
# List available virtual modules
zigts modules
zigts modules --json
# Show proof report (env vars, hosts, sandbox contract)
zigts check handler.ts --json
# Emit contract.json in the current directory
zigts check handler.ts --contract
# Preview compiler-authored local canonical refactors
zigts canonicalize handler.ts --json --simulate
# Contract comparison
zigts prove old-contract.json new-contract.json
Always use --json when running from an interactive expert client. Parse the result. Never guess at errors from unstructured stderr.
REPL (zigttp expert)
The interactive agent. Natural language is sent to the model by default. Slash commands run locally without a model call.
Slash Commands
| Command | Description |
|---|---|
| /meta | Compiler version, policy hash |
| /features | Allowed/blocked language features |
| /modules | Virtual module exports |
| /rule <code> | Explain a diagnostic code (e.g. ZTS303) |
| /search <keyword> | Search all rules by keyword |
| /verify <path...> | Check files for violations |
| /check <path> | Full handler analysis |
| /build <step> | Run a zig build step |
| /test [step] | Run tests |
| /resume | Resume the latest session for this cwd |
| /new | Start a fresh session |
| help or :h | Show command reference |
| quit, exit, or :q | Exit the REPL |
Explicit zigts subcommands (zigts meta, zigts features, etc.) also dispatch locally.
Launch Flags
# Approval
zigttp expert --yes # auto-approve all verified edits
zigttp expert --no-edit # auto-reject all verified edits
# Session
zigttp expert --session-id <id> # named session (resume or create)
zigttp expert --resume # resume newest session for this cwd
zigttp expert --no-session # disable session persistence
zigttp expert --no-persist-tool-output # omit tool output from session log
# Non-interactive
zigttp expert --print "add a GET /health route" # one-shot, plain text output
zigttp expert --print "..." --mode json # one-shot, NDJSON events to stdout
--mode json Event Format
With --print <prompt> --mode json, the agent emits one NDJSON line per event then exits. This is the same shape as events.jsonl session files, so one parser handles both.
{"v":1,"k":"user_text","d":"add a GET /health route"}
{"v":1,"k":"tool_use","d":{"id":"tu_01","name":"zigts_check","args_json":"{\"path\":\"handler.ts\"}"}}
{"v":1,"k":"tool_result","d":{"tool_use_id":"tu_01","tool_name":"zigts_check","ok":true,"body":"..."}}
{"v":1,"k":"model_text","d":"Added the route. Proof: retry_safe=true, idempotent=true."}
{"v":1,"k":"end"}
Event kinds: user_text, model_text, tool_use, tool_result, proof_card, diagnostic_box, end.
Field v is the schema version (currently 1). The end event always appears last.
Compiler-in-the-Loop Workflow
When writing or fixing zigts code, follow this exact loop:
1. Write the handler using the rules in this skill
2. Run: zigts check --json <file>
3. If success -> done, report the proof summary to the user
4. If errors -> read each diagnostic's `suggestion` field
5. Apply the suggestions (don't guess - the compiler knows the idiom)
6. Go to 2
Reading Compiler Output
Success:
{
"success": true,
"proof": {
"env_vars": ["JWT_SECRET"],
"outbound_hosts": ["api.stripe.com"],
"virtual_modules": ["zigttp:auth", "zigttp:cache"],
"properties": {
"retry_safe": true,
"idempotent": false,
"injection_safe": true,
"deterministic": false,
"read_only": false,
"state_isolated": true,
"fault_covered": true
}
},
"diagnostics": []
}
Error:
{
"success": false,
"diagnostics": [
{
"code": "ZTS001",
"severity": "error",
"message": "'try/catch' is not supported",
"file": "handler.ts",
"line": 23,
"column": 3,
"suggestion": "use Result types for error handling"
}
]
}
The suggestion field is authoritative. Follow it. Don't invent an alternative.
To record I/O for deterministic replay and test generation, see references/testing-replay.md (--trace, --replay, JSONL test format).
One-Way ZigTS
The one-way profile is the default strict profile for code that should remain easy for the compiler and agent to refactor mechanically. ZTS608+ diagnostics are errors, not advisory style warnings.
Rules:
- Prefer named
functiondeclarations for reused helpers. Keep arrow functions for callbacks and one-off local values. - Prefer
export function name(...)over exported function-valuedconstdeclarations unless the export is intentionally a first-class value. - Public helpers that reach capabilities should declare an explicit
Effects<...>return capsule. - Public helpers used under declared specs should declare an explicit
Proof<...>return capsule.
Canonical diagnostics start at ZTS608. Existing strict diagnostics keep their
current meanings: ZTS602 for dynamic capability keys, ZTS604 for avoidable
let, and ZTS606/ZTS607 for proof/effects failures.
Use zigts canonicalize <file> --json to preview compiler-authored local
rewrite intents for canonical diagnostics and mechanically safe strict-profile
repairs such as let to const. Add --simulate to have the compiler apply
the previews in memory and run edit-simulate; source files are never written.
Apply the replacement only through edit-simulate / compiler veto.
Diagnostic Code Ranges
- ZTS0xx: Parser errors (unsupported features, syntax)
- ZTS1xx: Sound mode / BoolChecker (type safety in conditions, arithmetic)
- ZTS2xx: TypeChecker (type mismatches, argument errors)
- ZTS3xx: HandlerVerifier (missing returns, unchecked results, state isolation)
- ZTS6xx: Strict and one-way ZigTS diagnostics
Language Rules - What zigts Is
zigts is a restricted TypeScript subset compiled by a Zig-based compiler. It removes features to guarantee that every handler is a pure function with a statically provable sandbox.
The Core Invariant
The IR tree IS the control flow graph. No cycles, no hidden exception paths, no non-local jumps. Every feature that would break this property is blocked.
What's Allowed
| Category | Features |
|----------|----------|
| Declarations | let, const, function, arrow functions, destructuring (array/object/rest) |
| Control flow | if/else, for...of with break/continue, return, assert |
| Expressions | Template literals, ternary, spread, optional chaining (?.), nullish coalescing (??) |
| Operators | + - * / % **, === !== < > <= >=, && || ! |
| Assignment | = += -= *= /= %= **= |
| Modules | import { x } from "zigttp:mod", import { x } from "./local", export |
| Special | match expression, pipe operator |>, guard() composition, comptime() |
| Types | Type aliases, distinct type, interfaces, annotations, readonly fields, type guards (x is T), template literal types |
What's Blocked (and Why)
| Banned | Use Instead |
|--------|-------------|
| switch/case | match expression |
| class | Plain objects and functions |
| while, do...while | for (const x of range(n)) or for (const x of collection) |
| C-style for (;;) | for (const i of range(n)) |
| for...in | for (const k of Object.keys(obj)) |
| try/catch/finally | Result types: check .ok before .value |
| throw | return Response.json({ error: "..." }, { status: 500 }) |
| var | let or const |
| null | undefined (sole absent-value sentinel) |
| ==, != | ===, !== (strict equality only) |
| ++, -- | x = x + 1, x = x - 1 |
| Regular expressions | String methods: includes, startsWith, endsWith, indexOf |
| new (constructors) | Factory functions, object literals |
| this | Explicit parameter passing |
| async/await/Promise | fetchSync(), parallel(), race() |
| delete | New object literal with only the keys you keep |
Error Handling
zigts has no try/catch. All errors flow through two patterns:
Result types: Functions like jwtVerify, validateJson return { ok: true, value: T } | { ok: false, error: string }. The verifier enforces checking .ok before accessing .value.
const result = jwtVerify(token, secret);
if (!result.ok) return Response.json({ error: result.error }, { status: 403 });
const claims = result.value;
Optional narrowing: Functions like env(), cacheGet(), parseBearer() return T | undefined. Four recognized patterns:
| Pattern | Example |
|---------|---------|
| Truthiness guard | if (val) { /* val is T */ } |
| Early return | if (!val) return ...; // val is T below |
| Undefined check | if (val !== undefined) { /* val is T */ } |
| Nullish coalesce | const v = val ?? "default"; // v is string |
Type Guards and Assert
Type guard functions declare narrowing with x is T return types. The assert statement applies the guard as permanent forward narrowing:
function isString(x: unknown): x is string {
return typeof x === "string";
}
// Branch narrowing
if (isString(val)) { val.toUpperCase(); }
// Forward narrowing (permanent from this point)
assert isString(val);
val.toUpperCase();
// With explicit error response
assert isString(name), Response.json({ error: "name required" }, { status: 400 });
Without an error expression, assert halts. With one, it returns that value.
Discriminated Union Narrowing
Unions with a tag field narrow through if conditions:
type Result = { kind: "ok", value: string } | { kind: "err", error: string };
if (r.kind === "err") {
return Response.json({ error: r.error }, { status: 400 });
}
// r is narrowed to { kind: "ok", value: string }
Distinct Types
distinct type creates nominal types. Values of different distinct types are incompatible even if they share the same base:
distinct type UserId = string;
distinct type SessionId = string;
const uid: UserId = UserId("usr_123");
const sid: SessionId = SessionId("sess");
// uid and sid are not interchangeable
Readonly and Template Literal Types
readonly fields reject assignment at compile time:
type Config = { readonly port: number; host: string };
cfg.port = 8080; // ERROR
Template literal types validate string patterns:
type ApiRoute = `/api/${string}`;
const good: ApiRoute = "/api/users"; // OK
const bad: ApiRoute = "/other"; // ERROR
Handler Structure
Every handler is a function named handler that receives a Request and returns a Response.
function handler(req: Request): Response {
return Response.json({ ok: true });
}
Request Properties
req.method // string: "GET", "POST", etc.
req.url // string: full URL with query string
req.path // string: URL path without query string
req.query // object: parsed query parameters
req.body // string | undefined: raw request body
req.headers // object: header name -> value (lowercase keys)
req.params // object: route parameters (after routerMatch)
Response Helpers
Response.json(data, init?) // application/json
Response.text(text, init?) // text/plain
Response.html(html, init?) // text/html
Response.redirect(url, status?) // 302 by default
// init: { status: 201, statusText: "Created", headers: { "X-Custom": "val" } }
Virtual Modules
All external capabilities come through zigttp:* virtual modules. Each import is a provable contract - the compiler records exactly which modules and bindings a handler uses.
Run zigts modules for the full list. Key modules:
import { env } from "zigttp:env";
import { sha256, hmacSha256, base64Encode, base64Decode } from "zigttp:crypto";
import { routerMatch } from "zigttp:router";
import { parseBearer, jwtVerify, jwtSign } from "zigttp:auth";
import { schemaCompile, validateJson } from "zigttp:validate";
import { cacheGet, cacheSet, cacheIncr } from "zigttp:cache";
import { parallel, race } from "zigttp:io";
import { guard, pipe } from "zigttp:compose";
import { logInfo, logError } from "zigttp:log";
import { serviceCall } from "zigttp:service";
import { send, close, getWebSockets, setAutoResponse, serializeAttachment, deserializeAttachment } from "zigttp:websocket";
Pattern Matching
The match expression matches values against object patterns. Every match must have a default arm.
function handler(req: Request): Response {
return match (req) {
when { method: "GET", path: "/health" }:
Response.json({ ok: true })
when { method: "POST", path: "/echo" }:
Response.json({ echo: req.body })
default:
Response.text("Not Found", { status: 404 })
};
}
Composition with pipe and guard
import { guard } from "zigttp:compose";
function rateLimiter(req: Request): Response | undefined {
const ip = req.headers["x-forwarded-for"] ?? "unknown";
const count = cacheIncr("ratelimit", ip, 1, 60);
if (count > 100) return Response.json({ error: "rate limited" }, { status: 429 });
}
function requireAuth(req: Request): Response | undefined {
const token = parseBearer(req.headers["authorization"]);
if (!token) return Response.json({ error: "unauthorized" }, { status: 401 });
const secret = env("JWT_SECRET");
if (secret === undefined) return Response.json({ error: "server misconfigured" }, { status: 500 });
const result = jwtVerify(token, secret);
if (!result.ok) return Response.json({ error: result.error }, { status: 403 });
}
const handler = guard(rateLimiter) |> guard(requireAuth) |> dashboard;
Sound Mode Type System
The BoolChecker enforces type-directed safety rules at compile time:
- Boolean contexts: only boolean, number, string, optional types accepted (not objects or functions)
- Arithmetic: both operands must be numbers
- Addition:
number + numberorstring + stringonly - mixed types are errors (use template literals) - Optionals: must be narrowed before use in arithmetic or addition
Built-in Globals
Object.keys/values/entries, array HOFs (map, filter, reduce, find, some, every, forEach), all String methods, Math, JSON.parse/stringify, Date.now(), console.log, range(end), fetchSync(url, init?), parseInt, parseFloat, structuredClone.
Compile-Time Features
comptime(expr): evaluate at load time (literals, arithmetic, Math, string methods, hash)-Dverify: proves exhaustive returns, Result checking, optional narrowing, state isolation-Dcontract: extracts sandbox contract (env vars, hosts, modules, properties)
Workflow Authoring
Durable workflow code is a disciplined composition of existing modules, not a
new DSL. For the copyable first shape, import run from zigttp:durable and
call from zigttp:workflow, derive the run key from
req.headers.get("idempotency-key"), and put the top-level workflow.call
inside run(). Use step() for replayable JSON-snapshot work only. Because
workflow modules are write-effect modules, declare a narrow Spec<...> with
only the properties zigts check --json proves.
Never put workflow.call, saga, fanout, or follow inside a durable
step() callback; ZTS509 rejects that shape. For saga([...]), every non-last
static step needs a compensate function or ZTS510 rejects it. fanout() is
ordered durable batch grouping, not true concurrency.
Writing Good zigts Code
- Start with the handler signature:
function handler(req: Request): Response - Import only what you need - every import is a sandbox contract
- Use
constfor everything - there is no mutation needed - Use Result types for fallibility - never throw
- Use
pipe()for sequencing - never nest callbacks - Use
match()for routing - never chain if/else on method+path - Type your functions - annotations drive the type checker and proof system
- One handler per file - the compiler's proof unit is a single file
When the Compiler Knows Best
If zigts check --json returns an error with a suggestion, use the suggestion. The compiler understands the restricted grammar better than any language model. The skill gives you the patterns; the compiler enforces them.
JSX/SSR
Use .tsx files for server-side HTML rendering. There is no client-side hydration. renderToString is the only rendering entry point.
type Todo = { id: number; text: string; done: boolean };
function TodoItem(props: { todo: Todo }): JSX.Element {
const cls = props.todo.done ? "done" : "pending";
return <li class={cls}>{props.todo.text}</li>;
}
function TodoList(props: { todos: Todo[] }): JSX.Element {
return (
<ul>
{props.todos.map((t) => <TodoItem todo={t} />)}
</ul>
);
}
function handler(req: Request): Response {
const todos: Todo[] = [
{ id: 1, text: "ship it", done: false },
];
const html = renderToString(<TodoList todos={todos} />);
return Response.html(html);
}
See references/jsx-patterns.md for the full component API, attribute rules, and fragment syntax.
Reference Files
references/virtual-modules.md- Full API documentation for allzigttp:*modulesreferences/testing-replay.md- JSONL test format, deterministic replay, build-time verificationreferences/jsx-patterns.md- JSX/TSX component patterns and SSR rendering