A transform edits a response body before it reaches the client. Reach for one when the shape an upstream returns is not the shape a caller needs: trimming fields from a JSON payload, converting HTML to Markdown for an LLM, capping a body size, or running a sandboxed script or WASM module over the bytes. Transforms never touch the request; for that, see the request modifier and forward-rule sections of configuration.md.
SBproxy ships 28 transform types plus noop. This page is the map: what a transform is, where it runs, the fields every transform shares, and a minimal working config for each kind. Full field references for the JSON/HTML/text transforms live inline below; the scripting, WASM, and agent-content transforms link out to their dedicated guides rather than duplicating them here.
Where a transform runs
Transforms are a response_body_filter stage. Per architecture.md's request pipeline:
response_filter:
CORS, HSTS, security headers, response modifiers, forward rule echo,
rate limit headers, Alt-Svc, CSRF cookie, session cookie, on_response
callbacks, traceparent echo.
response_body_filter:
Response cache write on miss, transform pipeline, fallback body swap.
So a transform chain runs after on_response callbacks and response header shaping have already happened, and after the response-cache write decision for that request. Each origin's chain is compiled once, at config load or reload, into an ordered Vec of compiled transforms; per-request execution walks that list with no allocation in the chain-construction path.
An origin whose action is ai_proxy never reaches this stage. Config load refuses transforms: on those origins and names ai_guardrail_output and ai_tool_call as the bundle hooks that inspect model output instead.
A transform entry
Every item in an origin's transforms: list is wrapped in the same pipeline-level fields, on top of whatever fields its own type needs:
| Field | Type | Default | Description |
|---|---|---|---|
type |
string | required | Transform type discriminator, e.g. json, wasm. |
content_types |
list | [] |
Content-Type substrings this transform applies to. A response's Content-Type header must contain one of the listed strings for the transform to run; empty matches every content type, and a response with no Content-Type header never matches a non-empty list. |
failure_posture |
string | open |
What happens to the response when this transform errors. open skips the failed transform and continues the chain with the next one. closed replaces the whole response body with a generic error instead of forwarding bytes the transform never finished producing. degraded and observe are rejected at config load; neither has a defined meaning for a transform yet. |
fail_on_error |
bool | false |
Legacy spelling of the same axis: true resolves to failure_posture: closed, false to open. Only read when failure_posture is absent. Setting both to values that disagree is a config-load error. |
max_body_size |
int | 10485760 |
Maximum body size, in bytes, this transform is willing to see. Under open, a larger body skips the transform and passes through unmodified; under closed, the response fails, because a body the transform never saw must not reach the client. |
disabled |
bool | false |
When true, the transform is still parsed and validated (so a config error in a disabled transform still fails config load) but excluded from the compiled chain. |
Chaining and order
Transforms run in the order they're listed. Each one reads the buffer the previous one left behind and writes back into the same buffer, so a later transform sees the earlier ones' output, not the original upstream body. Order is structural for some pairings: the boilerplate transform's own doc comment states it "must run BEFORE HtmlToMarkdownTransform. Doing it after Markdown projection would have nothing to strip; the projection has already discarded structural tags." The shipped four-step content-shaping chain (boilerplate -> html_to_markdown -> citation_block -> json_envelope, see content-for-agents.md) follows that same logic end to end.
Two things gate whether a transform runs at all on a given response, independent of its own fields: the content_types filter above, and disabled: true.
Response cache and request-dependent transforms. An origin with response_cache enabled stores each cached entry's transformed body, not the raw upstream response, and a stale-while-revalidate refresh reruns the chain with no request in scope. That's only sound for a transform whose output is a pure function of the response body, its content type, and its own static config. A transform that reads request state (the scripted transforms lua / lua_json / javascript / js_json, the content-negotiation family html_to_markdown / citation_block / json_envelope, cel, a2a_agent_card_rewrite, and every linked-plugin transform) is refused at config load when combined with response_cache on the same origin; the load error names the transform. A wasm transform is the one type in this list that is not a constant: it only becomes request-dependent, and only then joins this refusal, when its own request_context: true is set (see scripting.md §6.1); a wasm transform that leaves request_context unset stays cacheable. Move a refused transform to an origin without response_cache, or drop it from the chain, to keep the pairing.
JSON shaping
json - field manipulation
Set, remove, or rename fields on a JSON body. Order of operations inside one json transform is fixed: remove runs first, then rename, then set last (so set can overwrite a field that was just renamed into place).
| Field | Type | Default | Description |
|---|---|---|---|
set |
map | {} |
Fields to set or overwrite. Values may be any JSON. |
remove |
list | [] |
Field names to delete. |
rename |
map | {} |
old_name -> new_name mapping. |
transforms:
- type: json
rename:
userId: author_id
remove:
- body
set:
source: sbproxy
Verified against examples/transform-json/sb.yml.
json_projection - include/exclude fields
Keep or drop a flat field list. fields (alias include) is required; there's no nested projection: key.
| Field | Type | Default | Description |
|---|---|---|---|
fields |
list | required | Field names to keep (default) or drop (when exclude is true). Alias: include. |
exclude |
bool | false |
When true, drop the listed fields instead of keeping them. |
transforms:
- type: json_projection
fields: [id, title]
Verified against examples/transform-json-projection/sb.yml.
json_schema - response validation
Validate the response body against a JSON Schema document, compiled once at config load. Remote $ref resolution is disabled to prevent SSRF.
| Field | Type | Default | Description |
|---|---|---|---|
schema |
object | required | The JSON Schema document. |
transforms:
- type: json_schema
fail_on_error: true
schema:
type: object
required: [id, title]
properties:
id: { type: integer }
title: { type: string }
Verified against examples/transform-json-schema/sb.yml. A validation failure only produces a rejected response when failure_posture is closed (or the legacy fail_on_error: true); under the open default, a static action origin logs a warning and still serves the configured body unchanged.
ai_schema - AI structured-output enforcement
A second schema-shaped transform, aimed specifically at AI provider responses rather than any JSON body. Where json_schema validates with the standard jsonschema crate under the pipeline's shared failure_posture axis, ai_schema carries its own small type/required/properties/items validator and its own on_failure mode with three values (block, warn, passthrough), so an operator can calibrate a new schema against live traffic in warn before promoting it to block.
| Field | Type | Default | Description |
|---|---|---|---|
schema |
object | required | The schema document (a hand-rolled subset: type, required, properties, items, not full JSON Schema). |
on_failure |
string | block |
One of block, warn, passthrough. block refuses the response (an Err, subject to failure_posture like any other transform error); warn logs every violated path and forwards the response; passthrough forwards the response and says nothing. The set is closed: any other value is refused at config load, so a typo cannot quietly downgrade a blocking check to a no-op. |
transforms:
- type: ai_schema
on_failure: warn
schema:
type: object
required: [choices]
properties:
choices: { type: array }
Verified against examples/ai-schema-enforcement/sb.yml.
Text, encoding, and format
template - render the body through minijinja
Renders the JSON body as input data to a minijinja (Jinja-style) template.
| Field | Type | Default | Description |
|---|---|---|---|
template |
string | required | Template source with {{ variable }} syntax. |
transforms:
- type: template
template: |
Order {{ order_id }} for {{ customer }}
Verified against examples/transform-template/sb.yml.
replace_strings - literal or regex find-and-replace
| Field | Type | Default | Description |
|---|---|---|---|
replacements |
list | required | Ordered list of replacement rules. |
replacements[].find |
string | required | Literal substring or regex pattern. |
replacements[].replace |
string | required | Replacement string. |
replacements[].regex |
bool | false |
When true, treat find as a regex. |
transforms:
- type: replace_strings
replacements:
- find: "internal.example.com"
replace: "public.example.com"
- find: '\d{16}'
replace: "[REDACTED]"
regex: true
Verified against examples/transform-replace-strings/sb.yml.
normalize - whitespace and newline cleanup
| Field | Type | Default | Description |
|---|---|---|---|
trim |
bool | false |
Trim leading and trailing whitespace. |
collapse_whitespace |
bool | false |
Collapse runs of spaces and tabs into a single space. |
normalize_newlines |
bool | false |
Replace \r\n with \n. |
transforms:
- type: normalize
trim: true
collapse_whitespace: true
normalize_newlines: true
No example directory ships this transform in isolation; the snippet above is built directly from NormalizeTransform's config struct in crates/sbproxy-modules/src/transform/text.rs, not from a runnable fixture.
encoding - base64/URL encode or decode
| Field | Type | Default | Description |
|---|---|---|---|
encoding |
string | required | One of base64_encode, base64_decode, url_encode, url_decode. |
transforms:
- type: encoding
encoding: base64_encode
Verified against examples/transform-encoding/sb.yml.
format_convert - JSON/YAML conversion
| Field | Type | Default | Description |
|---|---|---|---|
from |
string | required | Source format: json or yaml. |
to |
string | required | Target format: json or yaml. |
transforms:
- type: format_convert
from: json
to: yaml
No example directory ships this transform; the snippet is built directly from FormatConvertTransform's config struct.
Body size and streaming control
payload_limit - cap response size
| Field | Type | Default | Description |
|---|---|---|---|
max_size |
int | required | Maximum allowed body size in bytes. |
truncate |
bool | false |
When true, truncate to max_size. When false, error on oversize. |
transforms:
- type: payload_limit
max_size: 256
truncate: true
Verified against examples/transform-payload-limit/sb.yml.
discard - drop the body entirely
Takes no fields. Replaces the body with an empty one; useful for beacon-style endpoints or forced-empty responses.
transforms:
- type: discard
sse_chunking - Server-Sent Events framing
Formats the body as SSE with the configured line prefix and double-newline event delimiters.
| Field | Type | Default | Description |
|---|---|---|---|
line_prefix |
string | "data: " |
Prefix prepended to each non-empty line. |
transforms:
- type: sse_chunking
line_prefix: "data: "
No example directory ships this transform; the snippet is built directly from SseChunkingTransform's config struct in crates/sbproxy-modules/src/transform/control.rs.
HTML, Markdown, and CSS
html - element removal, injection, attribute rewrites
| Field | Type | Default | Description |
|---|---|---|---|
remove_selectors |
list | [] |
Tag names or #id selectors to strip. |
inject |
list | [] |
{position, content} entries. position is head_end, body_start, or body_end. |
rewrite_attributes |
list | [] |
{selector, attribute, value} entries. Every tag the selector matches is stamped: a tag that already carries the attribute has its value replaced, and a tag that does not gets the attribute added. The tag's attribute list is what gets read, so an unquoted upstream value (<a target=_self>) is replaced and requoted rather than duplicated, and the same characters inside a different attribute's value are left alone. |
format_options |
object | none | Optional post-manipulation HTML optimization (see optimize_html below for the sub-fields). |
transforms:
- type: html
remove_selectors: [h1]
inject:
- position: head_end
content: '<link rel="stylesheet" href="https://cdn.example.com/sbproxy.css">'
Verified against examples/transform-html/sb.yml.
optimize_html - minify
| Field | Type | Default | Description |
|---|---|---|---|
remove_comments |
bool | true |
Strip <!-- ... --> comments. |
collapse_whitespace |
bool | true |
Collapse runs of whitespace into a single space (preserves <pre> and <code> content). |
remove_optional_tags |
bool | false |
Remove optional closing tags such as </li>, </p>, </tr> (experimental). |
transforms:
- type: optimize_html
No example directory ships this transform standalone; the snippet and defaults come directly from OptimizeHtmlTransform's config struct.
html_to_markdown - HTML to Markdown projection
| Field | Type | Default | Description |
|---|---|---|---|
heading_style |
string | "atx" |
Heading style: atx (# headings) or setext (underlined). |
transforms:
- type: html_to_markdown
heading_style: atx
Verified against examples/transform-html-to-markdown/sb.yml. This is also the second step of the content-shaping chain covered in content-for-agents.md; the two uses are the same transform.
pdf_markdown - PDF to Markdown projection
Replaces an application/pdf response body with a Markdown projection (the same MarkdownProjection shape html_to_markdown produces: body, title, token_estimate), so a downstream JSON envelope or x-markdown-tokens header sees the same shape regardless of which source format the origin served. A non-PDF response passes through untouched.
Requires the transform-pdf cargo feature (off by default): pdf-extract and lopdf, the two pure-Rust decoders this transform uses, together pull roughly 70 transitive crates that most deployments never need. Build with --features sbproxy-modules/transform-pdf (or the equivalent feature on the sbproxy binary crate) to enable it; a route configured with type: pdf_markdown against a binary built without the feature fails config load with a message naming it.
| Field | Type | Default | Description |
|---|---|---|---|
max_pages |
int | 50 |
Hard cap on pages walked. Pages past this index are dropped; a (... PDF truncated at N pages ...) note is appended. |
extract_text |
bool | true |
Walk the content stream and emit page text. false produces an empty body with the title still resolved. |
extract_tables |
bool | false |
Reserved; table-layout reconstruction is not implemented. Setting true logs a warning and is otherwise a no-op. |
token_bytes_ratio |
float | 0.25 |
Bytes-per-token ratio for the token_estimate field, same default as html_to_markdown. |
transforms:
- type: pdf_markdown
max_pages: 50
Title resolution order: the PDF's /Info /Title trailer entry, then the first heading-like line of the decoded body, then the literal "Untitled PDF". A decode failure (a corrupted document, an unsupported font encoding, or a zero-byte body) is an Err, so it follows the same failure_posture handling as every other transform's error and never forwards the untransformed PDF bytes. Verified against examples/transform-pdf-markdown/sb.yml.
markdown - Markdown to HTML
Converts Markdown to HTML via pulldown-cmark.
| Field | Type | Default | Description |
|---|---|---|---|
smart_punctuation |
bool | false |
Curly quotes, smart dashes. |
tables |
bool | false |
GitHub-flavored tables. |
strikethrough |
bool | false |
~~strikethrough~~ support. |
transforms:
- type: markdown
tables: true
strikethrough: true
smart_punctuation: true
Verified against examples/transform-markdown/sb.yml.
css - stylesheet injection and pruning
| Field | Type | Default | Description |
|---|---|---|---|
inject |
list | [] |
CSS rules to append. |
remove_selectors |
list | [] |
Selectors whose rule blocks are removed. |
minify |
bool | false |
Minify the output. |
transforms:
- type: css
inject:
- "body { background: #fafafa; }"
remove_selectors: [".legacy-banner"]
minify: true
No example directory ships this transform standalone; the snippet is from configuration.md's Transforms reference, itself sourced from CssTransform's config struct.
Scripting transforms
Six transform types hand the body to a scripting or sandboxing engine instead of a fixed-shape config. Each is documented in depth in scripting.md; this page only shows the minimal wiring.
lua
Runs a Lua transform(body, ctx) function over the raw body string; the return value (a non-string return is JSON-serialized) replaces it. Unlike lua_json, the body is never parsed as JSON, so this is the transform to reach for on plain text, XML, CSV, or a body that is only sometimes JSON. Optional function_name picks a different entrypoint. A script with no transform function falls back to the legacy format: top-level code with the raw body bound to a body global.
transforms:
- type: lua
script: |
function transform(body, ctx)
return string.upper(body)
end
No example directory ships this transform standalone; the snippet is built directly from LuaTransform's config struct in crates/sbproxy-modules/src/transform/mod.rs. examples/transform-lua/sb.yml demonstrates the JSON sibling, lua_json, below.
lua_json
Runs a Lua modify_json(data, ctx) function over the parsed JSON body; the return value replaces it. Alias field: lua_script.
transforms:
- type: lua_json
script: |
function modify_json(data, ctx)
data.processed = true
return data
end
Verified against examples/transform-lua/sb.yml.
javascript
Runs a JavaScript transform(body, ctx) function over the raw body string (a non-string return is JSON-serialized). Optional function_name picks a different entrypoint.
transforms:
- type: javascript
script: |
function transform(body, ctx) {
return body.toUpperCase();
}
Verified against examples/transform-javascript/sb.yml.
js_json
Runs a JavaScript modify_json(data, ctx) function over the parsed JSON body. Optional function_name picks a different entrypoint. Alias field: js_script.
transforms:
- type: js_json
script: |
function modify_json(data, ctx) {
data.processed = true;
return data;
}
Verified against scripting.md section 5.
cel
Sets, appends, or removes response headers via CEL value_expr rules. It cannot write a response body: see scripting.md for why on_request: and on_response: were removed from this transform's config rather than kept as unenforced keys.
transforms:
- type: cel
headers:
- { op: set, name: x-served-by, value_expr: '"sbproxy"' }
- { op: remove, name: x-internal-trace }
Verified against scripting.md section 3.6.
wasm
Pipes the body through a sandboxed WASI module's stdin/stdout (timeout_ms, max_memory_pages, max_fuel, sha256 control the sandbox). Full authoring contract, the module lifecycle, and sandbox boundaries are in wasm-development.md.
transforms:
- type: wasm
module_path: examples/wasm/echo-rust/echo.wasm
timeout_ms: 500
max_memory_pages: 256
Verified against examples/wasm-transform/sb.yml.
Content-shaping for agents
boilerplate, citation_block, and json_envelope exist to serve the same page as HTML to a browser and as Markdown or a structured JSON envelope to an AI agent, chained with html_to_markdown. Authoring any one of them (most commonly json_envelope) opts the origin into the compiler's auto_content_negotiate wiring. The full mechanism, including the Accept-based two-pass negotiation and the four well-known projection documents, is content-for-agents.md; this section only covers what each transform's own config accepts.
boilerplate
Strips <nav>, <footer>, <aside>, and comment/ad/sidebar <div> blocks from HTML before a Markdown projection runs. Ships with no config fields today.
transforms:
- type: boilerplate
citation_block
Prepends a Markdown citation line naming the source URL and license when the matched ai_crawl_control tier requires it.
| Field | Type | Default | Description |
|---|---|---|---|
force_citation |
bool (optional) | none | Standalone override when no tier matched. None defers to RequestContext::citation_required. |
transforms:
- type: citation_block
force_citation: true
json_envelope
Wraps the Markdown projection in the versioned JSON envelope schema (schema_version, title, url, license, content_md, fetched_at, citation_required, token_estimate, and pass-through schema_org). Takes no config fields; it's a no-op unless the negotiated content shape is JSON and an upstream html_to_markdown has already populated the projection.
transforms:
- type: json_envelope
The full four-step chain, verified together, in order, with the compiler's auto-wiring note inline:
transforms:
- type: boilerplate
- type: html_to_markdown
- type: citation_block
- type: json_envelope
Verified against examples/content-shape-negotiation/sb.yml and examples/markdown-for-agents/sb.yml.
A2A agent-card rewriting
a2a_agent_card_rewrite
Rewrites the url, endpoint, and nested agent.url fields on JSON responses served at A2A discovery paths, so a client that fetches the card keeps routing follow-up calls through the proxy instead of jumping straight at the upstream. Full discovery-flow context and the served-card precedence rules are in a2a-gateway.md.
| Field | Type | Default | Description |
|---|---|---|---|
paths |
list | the three well-known A2A discovery paths | Request paths that trigger the rewrite. An empty list collapses to the default set. |
proxy_host |
string (optional) | none | Hostname (or host:port) substituted into rewritten URLs. When unset, the inbound Host header is used. |
transforms:
- type: a2a_agent_card_rewrite
paths:
- /.well-known/agent-card.json
proxy_host: proxy.example.com
No standalone example directory exercises this transform's YAML in isolation (the a2a-protocol and a2a-prompt-injection examples configure the a2a action, not this transform); the snippet above is from the transform's own doc comment in crates/sbproxy-modules/src/transform/a2a_agent_card_rewrite.rs, not a runnable fixture.
Testing
noop
Passes the body through unchanged. Real and dispatched (compile.rs matches "noop" the same as any built-in transform type), but it exists for tests and plugin scaffolding rather than production traffic shaping.
transforms:
- type: noop
Custom transforms
A type: string the built-in match arms don't recognize falls through to two further tiers: a linked plugin registered via sbproxy-plugin's TransformPluginRegistration and the TransformHandler trait (see plugins.md), and then a config-loaded JavaScript or WASM extension bundle attaching a transforms[] hook (see extension-bundles.md). Both run after every built-in type: string has already failed to match, so a custom transform can't shadow a built-in name.
See also
- configuration.md - full
sb.ymlfield reference, including the origin schema transforms live under. - scripting.md - CEL, Lua, JavaScript, and WASM engines, sandbox limits, and the shared
ctxcontext table. - wasm-development.md - authoring WASI modules for the
wasmtransform. - content-for-agents.md - the full content-negotiation and agent-content pillar that
boilerplate/citation_block/json_envelope/html_to_markdownserve. - a2a-gateway.md - A2A discovery, agent-card serving, and
a2a_agent_card_rewrite. - plugins.md - writing a linked
TransformHandlerplugin. - extension-bundles.md - JavaScript/WASM extension bundles that attach
transforms[]hooks dynamically.