HTTP response
response is the second parameter every controller receives. It’s a helper-rich object whose job is to make picking the right HTTP outcome a one-line choice. Status codes live inside the helpers — response.notFound(...) already knows it’s a 404; you almost never set a status by hand.
This page is the complete surface, grouped by what you’re trying to do. Reach for it when autocomplete isn’t enough or you want to confirm a helper exists before writing it yourself.
Mental model
Section titled “Mental model”A Response builds up state — body, headers, cookies, status — and then sends it. The helpers (success, notFound, redirect, sendFile) wrap the right combination of those primitives for you. Most controllers end with one helper call:
return response.success({ products });A few rules the framework enforces:
- First helper wins. Calling two terminal helpers from the same handler logs a warning; only the first one reaches the wire.
- Streaming and SSE take over. Once you call
response.stream()orresponse.sse(), you cannot follow up withresponse.success(...)— call.end()on the controller they return. - Return the helper.
return response.success(...)— the return value drives the framework’s send pipeline.
import type { RequestHandler } from "@warlock.js/core";
export const showController: RequestHandler = async ({ request, response }) => { // build up state via helpers response.header("X-Total-Count", "42");
// terminate with one helper return response.success({ ok: true });};Success helpers
Section titled “Success helpers”| Method | Status | When |
|---|---|---|
response.success(data?) | 200 | normal read / update |
response.successCreate(data) | 201 | resource created (POST) |
response.accepted(data?) | 202 | async work accepted, not yet processed |
response.noContent() | 204 | delete succeeded, no body needed |
return response.success({ products: [...] });
return response.successCreate({ product });
return response.accepted({ jobId: enqueued.id });
return response.noContent();response.success() (no argument) defaults to { success: true } — useful for void operations that still need a body.
Client-error helpers
Section titled “Client-error helpers”| Method | Status | When |
|---|---|---|
response.badRequest(data) | 400 | malformed or invalid input |
response.unauthorized(data?) | 401 | missing/invalid auth |
response.forbidden(data?) | 403 | authenticated but not allowed |
response.notFound(data?) | 404 | record missing |
response.conflict(data?) | 409 | uniqueness violation, state conflict |
response.unprocessableEntity(data) | 422 | semantic validation error |
return response.badRequest({ error: t("validation.invalid") });
return response.unauthorized({ error: t("auth.invalidCredentials") });
return response.forbidden({ error: t("permission.denied") });
return response.notFound({ error: t("product.notFound") });
return response.conflict({ error: t("product.duplicateSku") });
return response.unprocessableEntity({ error: t("payment.cardDeclined") });unauthorized, forbidden, and notFound each default to a { error: "<status name>" } payload when called with no argument — fine for quick guards, but usually you want a translated message.
Translated errors
Section titled “Translated errors”Use the t() helper from @warlock.js/core for localized messages — it reads locale from the current request:
import { t } from "@warlock.js/core";
if (!result) { return response.unauthorized({ error: t("auth.invalidCredentials") });}Real example from the reference codebase:
import { t, type Request, type RequestHandler } from "@warlock.js/core";import { type LoginSchema, loginSchema } from "../schema/login.schema";import { loginUseCase } from "../use-cases/login.usecase";
export const login: RequestHandler<Request<LoginSchema>> = async ({ request, response }) => { const result = await loginUseCase({ data: request.validated(), deviceInfo: { userAgent: request.userAgent, ip: request.ip }, });
if (!result) { return response.unauthorized({ error: t("auth.invalidCredentials") }); }
return response.success(result);};
login.validation = { schema: loginSchema };Server-error helpers
Section titled “Server-error helpers”| Method | Status | When |
|---|---|---|
response.serverError(data) | 500 | unexpected failure |
response.serviceUnavailable(data) | 503 | downstream is down, retry later |
return response.serverError({ error: "Unexpected" });
return response.serviceUnavailable({ error: "Provider unavailable, retry in a minute",});You rarely call serverError directly — uncaught exceptions are mapped to it automatically by the framework’s error handler (see inject-request-context.ts). Reach for it only when you want to surface a specific upstream failure without throwing.
Redirects
Section titled “Redirects”return response.redirect("/login"); // 302return response.redirect("/new-home", 301); // status overridereturn response.permanentRedirect("/new-home"); // 301 shortcutresponse.redirect(url, status?) sets the Location header and the status code. Default is 302 (temporary). Use 301 for permanent moves so search engines update their indexes.
Redirect targets from the request
Section titled “Redirect targets from the request”A target taken from the request (?next=, returnTo, the Referer) is
attacker-controlled: following it as-is is an open redirect, and a
javascript: target runs script. Pass it through safeRedirect():
import { safeRedirect } from "@warlock.js/core";
return response.redirect( safeRedirect(request.input("next"), { allowedOrigins: ["https://app.example.com"], fallback: "/dashboard", }),);safeRedirect(target, options?) returns the target when it is a path on this
origin (/posts/1?tab=2) or an absolute http:/https: URL whose origin is in
allowedOrigins, and the fallback (default "/") otherwise. It refuses
//host and /\host, other schemes, paths that do not start with /,
credentials in the URL and any control character. isSafeRedirect(target, options?) is the boolean form. In pages, use safeRedirectTarget() from
@warlock.js/web. warlock check
reports request input reaching a redirect without one of them as WL9007.
// stream a file from diskreturn response.sendFile("/abs/path/to/file.pdf");
// cache for 1 year (default)return response.sendCachedFile("/abs/path/to/asset.css");
// send a Buffer with content typereturn response.sendBuffer(buffer, { contentType: "image/png" });SendFileOptions and SendBufferOptions cover the common knobs:
| Option | Type | Effect |
|---|---|---|
cacheTime | number | seconds for Cache-Control: max-age=... |
immutable | boolean | adds immutable to Cache-Control |
inline | boolean | true → render inline; false → download attachment |
filename | string | download name (used in Content-Disposition) |
contentType | string | (buffer only) MIME type |
etag | string | (buffer only) ETag header for conditional requests |
sendFile (and sendBuffer / sendImage with an etag) answer conditional requests out of the box, in the RFC 9110 order:
If-Matchuses strong comparison: only the exact strong tag (or*) passes; a weakW/"…"tag never does. A failedIf-Matchanswers412 Precondition Failed. WithoutIf-Match, anIf-Unmodified-Sinceolder than the file does the same.If-None-Matchuses weak comparison:W/"abc"and"abc"match each other. A match answers304for GET and HEAD (412for other methods).If-Modified-Sinceis read only whenIf-None-Matchis absent, at second precision.
If the path doesn’t exist, sendFile returns 404 with { error: "File Not Found" } automatically.
The same comparison is exported for your own handlers:
import { evaluateConditionalRequest, type RequestHandler } from "@warlock.js/core";
export const showDocumentController: RequestHandler = async ({ request, response }) => { const etag = `"doc-42-v7"`;
const outcome = evaluateConditionalRequest({ method: request.method, etag, ifMatch: request.header("if-match"), ifNoneMatch: request.header("if-none-match"), });
if (outcome === "not-modified") return response.setStatusCode(304).send(); if (outcome === "precondition-failed") return response.setStatusCode(412).send();
return response.header("ETag", etag).success({ id: 42 });};matchesIfNoneMatch(header, etag) and matchesIfMatch(header, etag) expose the two comparisons on their own.
Public-folder and router.directory() files are answered by @fastify/static, which applies its own conditional-request handling to the weak ETags it generates.
For attachment-style downloads:
return response.downloadFile("/abs/path/report.pdf", "report-2026.pdf");// or alias:return response.download("/abs/path/report.pdf");The framework sets Content-Type: application/octet-stream and Content-Disposition: attachment; filename=... for you.
response.xml(body, statusCode?) sends Content-Type: application/xml. The
body is either a raw XML string or any XMLable, meaning any object with a
toXML(): string method:
import type { XMLable } from "@warlock.js/core";
return response.xml('<?xml version="1.0" encoding="UTF-8"?><ok/>');
class Feed implements XMLable { toXML() { return '<?xml version="1.0" encoding="UTF-8"?><feed/>'; }}
return response.xml(new Feed());XMLable is matched by shape, so a package can satisfy it without importing
core. A @warlock.js/sitemap Sitemap is one example:
import { Sitemap } from "@warlock.js/sitemap";
const sitemap = new Sitemap({ baseUrl: "https://example.com" }).add({ path: "/" });
return response.xml(sitemap);Any other value throws. xml() is for bounded bodies only: a string or a
single Sitemap, which is limited to 50,000 URLs. A SitemapIndex has no
toXML(). It writes files that you serve directly. A Warlock web app does
this for you through
web.sitemap.
Streams
Section titled “Streams”response.stream(contentType?, options?) returns a stream controller — for large dynamic payloads where buffering would blow memory, or for progressive rendering:
const stream = response.stream("text/plain");
stream.send("first chunk\n");stream.send("second chunk\n");stream.send("third chunk\n");stream.end();The controller’s surface:
| Method | Effect |
|---|---|
stream.send(data) | write a chunk (string / Buffer) without waiting; ignored once the client left, throws after end() |
stream.write(data) | write a chunk and wait until the client can take more; resolves false once the stream ended or the client left |
stream.render(reactElement) | server-render a React node and write it as HTML |
stream.drain() | wait for buffered output to reach the client; resolves false once the stream ended or the client left |
stream.backpressured | boolean — is the connection holding more than it should |
stream.signal | AbortSignal aborted when the client disconnects (not on end()) |
stream.onDisconnect(fn) | run fn when the client disconnects |
stream.end() | finish the response |
stream.ended | boolean — has the stream ended (manually or via disconnect) |
Once you call stream(), the response is “claimed” — don’t call success(...) or any other terminal helper afterward.
stream.send() does not wait. For a stream that can outrun its reader, loop on await stream.write(chunk) and stop when it resolves false — the same pattern as SSE below. Written chunks are not kept: after end(), response.body holds the last retainChunks chunks, none by default. Keep a short tail for logging or tests with response.stream("text/plain", { retainChunks: 20 }).
Server-Sent Events
Section titled “Server-Sent Events”response.sse() is purpose-built for one-way server-to-client push — live notifications, progress updates, streaming LLM replies. Cheaper than websockets when the browser only needs to receive.
const sse = response.sse();
sse.send("message", { text: "Hello" });sse.send("notification", { type: "info", body: "Update available" }, "msg-123");
sse.end();The controller’s surface:
| Method | Effect |
|---|---|
sse.send(event, data, id?) | emit an event (data JSON-stringified). id is optional, used by the browser’s Last-Event-ID. |
sse.write(event, data, id?) | emit an event and wait until the client can take more; resolves false once the client is gone |
sse.drain() | wait for buffered output to reach the client; resolves false once the stream ended or the client left |
sse.backpressured | boolean — the transport is holding more than its high-water mark |
sse.signal | AbortSignal aborted when the client disconnects (not on end()) — pass it to the producer |
sse.comment(text) | write an SSE comment (invisible to the client) — useful for keep-alive pings |
sse.onDisconnect(handler) | register cleanup that runs when the client disconnects (cancel jobs, unsubscribe listeners) |
sse.end() | finish the stream |
sse.ended | boolean — has the stream ended (manually or via disconnect) |
Browsers consume SSE via new EventSource(url). The framework already sets text/event-stream, Cache-Control: no-cache, and disables nginx buffering for you.
The event name and id are single-line fields, so line breaks (and NUL) in them are removed before they are written (toSingleLineSseField() is the same function, exported for your own SSE writers); a value built from user input cannot inject extra fields or events. data is always written one data: line per line, so it needs no escaping. A multi-line sse.comment() is written as one comment line per line. An onDisconnect handler that throws is logged and does not stop the handlers registered after it.
Long-lived and high-volume streams
Section titled “Long-lived and high-volume streams”sse.send() does not wait: if the producer is faster than the client, the output piles up in memory. For a terminal, a log tail or any stream that can outrun its reader, use await sse.write(...) — it resolves once the client has caught up, and resolves false when the client is gone, so the loop stops:
const sse = response.sse();
for await (const line of tail(file, { signal: sse.signal })) { if (!(await sse.write("line", { line }))) break;}
sse.end();Sent events are not kept. After end(), response.body holds the last retainEvents events, none by default, so a stream that runs for hours uses the same memory as one that runs for seconds. Keep a short tail for logging or tests with response.sse({ retainEvents: 20 }).
Real SSE example — streaming an AI reply
Section titled “Real SSE example — streaming an AI reply”This is the actual stream controller from the reference codebase. It pipes an AI message’s chunks to the browser, cleans up on disconnect, and sends periodic keep-alives:
import { type Request, type RequestHandler } from "@warlock.js/core";import { chatMessageEventBus, type AIStreamEvent } from "../events/chat-message.event-bus";
export const streamAiMessageController: RequestHandler = async ({ request, response }) => { const aiMessageId = request.input("id"); const sse = response.sse();
const listener = (event: AIStreamEvent) => { if (event.type === "chunk") { sse.send("chunk", { chunk: event.chunk }); } else if (event.type === "done") { sse.send("done", { model: event.model, usage: event.usage }); sse.end(); } else if (event.type === "error") { sse.send("error", { message: event.message }); sse.end(); } };
chatMessageEventBus.subscribe(aiMessageId, listener); sse.onDisconnect(() => chatMessageEventBus.unsubscribe(aiMessageId, listener));
const keepAlive = setInterval(() => sse.comment("ping"), 25_000); sse.onDisconnect(() => clearInterval(keepAlive));};Three things to notice:
- The listener decides which SSE event name to emit (
chunk,done,error). sse.onDisconnect(...)is registered twice — once to unsubscribe from the event bus, once to clear the keep-alive interval. Both fire on client drop.- Background jobs writing to a dead socket are a silent no-op — the framework swallows further
sse.send(...)calls after disconnect.
Streaming a Warlock AI agent
Section titled “Streaming a Warlock AI agent”If you’re producing the events yourself with @warlock.js/ai, the producer side looks like:
import { ai } from "@warlock.js/ai";
const sse = response.sse();const run = await chatAgent.stream(request.input("message"));
for await (const event of run.events) { if (event.type === "text-delta") { sse.send("delta", { text: event.text }); } else if (event.type === "done") { sse.send("done", { usage: event.usage }); sse.end(); }}See @warlock.js/ai/skills/subskills/agent.md for the agent’s event surface.
Streaming React (streamReact)
Section titled “Streaming React (streamReact)”New in 5.12. response.streamReact(pipeableStream) pipes a React server
stream — the PipeableStream returned by React’s
renderToPipeableStream — onto the raw response. It’s the framework’s Stage 1
streaming SSR seam: @warlock.js/web uses it exclusively to stream a page
document, and it never touches the raw response itself.
Reach for it when you’re writing a custom, non-page route (an API
controller, not a .page.tsx file) that needs to stream a React tree instead
of buffering a full HTML string first — a hand-rolled SSR endpoint outside the
web page pipeline.
import { renderToPipeableStream } from "react-dom/server";import type { PipeableReactStream, RequestHandler } from "@warlock.js/core";import { Widget } from "./widget";
export const renderWidget: RequestHandler = async ({ response }) => { const pipeableStream = await new Promise<PipeableReactStream>((resolve, reject) => { const stream = renderToPipeableStream(<Widget />, { onShellReady: () => resolve(stream), onShellError: reject, }); });
response.header("Content-Type", "text/html");
await response.streamReact(pipeableStream);};Two guarantees:
- Status and headers are written before the first byte.
streamReactcalls the raw response’swriteHead(statusCode, headers)with whatever status and headers were already committed onresponsebefore piping starts — decide those first, then callstreamReact. - A client disconnect aborts the render. If the connection closes before
the stream finishes, the underlying React stream’s
abort()is called, so a dropped connection doesn’t leave the render running to completion for nobody.
streamReact returns a Promise<void> that resolves once the response has
finished sending, and rejects if the raw response errors while writing.
Cookies
Section titled “Cookies”response.cookie("session_id", "abc.def.ghi", { raw: true });response.cookie("preferences", { lang: "en", notifications: true }); // objects auto-JSON-stringifiedresponse.cookie("theme", "dark", { httpOnly: false }); // deliberately JS-readableresponse.clearCookie("session_id");Cookie options come from Fastify’s @fastify/cookie (CookieSerializeOptions): domain, path, secure, httpOnly, sameSite, maxAge, expires, signed.
Secure by default
Section titled “Secure by default”Since 4.10.0 every response cookie gets these unless you override them:
| Flag | Default | Without it |
|---|---|---|
httpOnly | true | any injected script can read the cookie |
sameSite | "lax" | the cookie rides along on cross-site requests |
secure | true, except in development | the cookie travels in cleartext |
secure is relaxed in development only — a browser drops a Secure cookie over plain http, which would silently break every local login. It stays on in test and staging.
These are the flags whose absence never fails a test and is fatal in production: the app works perfectly and is simply insecure. Making them the default means opting out is explicit and visible in review.
Precedence
Section titled “Precedence”Lowest to highest: framework defaults → http.cookies.options → the per-call options argument.
import { env } from "@warlock.js/core";
const httpConfigurations = { cookies: { secret: env("COOKIE_SECRET", "super-secret-key-change-me"), options: { // Framework defaults already cover httpOnly / sameSite / secure — // set only what you want to differ app-wide. path: "/", }, },};Headers
Section titled “Headers”response.header("X-Total-Count", "42");response.header("Cache-Control", "no-cache");
response.headers({ "X-A": "1", "X-B": "2" }); // batch set
response.removeHeader("X-Internal");const ct = response.getHeader("Content-Type");const all = response.getHeaders();header(name, value) chains — you can call it multiple times before the terminal helper.
A header value built from request input or stored user data goes through
escapeHeaderValue() first. It removes CR, LF, NUL and other control characters
(tabs stay), replaces characters outside ISO-8859-1 with ? and trims, so the
value can neither split the header nor make Node throw ERR_INVALID_CHAR (a
500):
import { escapeHeaderValue } from "@warlock.js/core";
response.header("X-Export-Name", escapeHeaderValue(request.input("name")));Status code (escape hatch)
Section titled “Status code (escape hatch)”Most of the time the helper carries the status. When you genuinely need to set one by hand (custom 418, a 207 multi-status for batch jobs):
return response.setStatusCode(207).send({ results });send(data, statusCode?) is the underlying terminal — every helper above is sugar over it. Avoid calling it directly when a helper exists; the helper’s semantic is what makes the controller readable.
Patterns by use-case
Section titled “Patterns by use-case”Created with a Location header
Section titled “Created with a Location header”const product = await createProductService(request.validated());
response.header("Location", `/products/${product.id}`);
return response.successCreate({ product });Conditional 404 vs 200
Section titled “Conditional 404 vs 200”const product = await getProductService(request.input("id"));
if (!product) { return response.notFound({ error: t("product.notFound") });}
return response.success({ product });Heavy file with cache
Section titled “Heavy file with cache”return response.sendFile("/var/data/report.pdf", { cacheTime: 60 * 60, inline: true, filename: "report.pdf",});Synthetic event stream (line-by-line)
Section titled “Synthetic event stream (line-by-line)”const stream = response.stream("text/plain");
for (const line of generateLines()) { stream.send(line + "\n");}
stream.end();Setting then sending
Section titled “Setting then sending”response.header("X-Cache", "MISS");response.cookie("seen_at", new Date().toISOString());
return response.success({ data });Gotchas
Section titled “Gotchas”- Don’t manually call
sendwith a hand-rolled status when a helper exists.response.send(data, 404)works, but loses the error-event integration and reads worse thanresponse.notFound(data). The helpers are the public API. - First helper wins. Two terminal calls log a warning; only the first one writes to the wire. If you find yourself wanting “send-or-fail-quietly” logic, branch with an
if. stream()andsse()consume the response. Nosuccess(...)afterward — callstream.end()/sse.end()to finish. Background work writing afterend()is silently dropped.- Don’t set
secure: Application.isProductionfor cookies any more. Since 4.10.0securealready tracks the environment — on everywhere except development. Writing it by hand is not just redundant, it’s narrower: it turnssecureoff in test and staging, where the default keeps it on. response.success()with no arg returns{ success: true }. If you want truly empty, useresponse.noContent()(204).sendFile(path)checks file existence and returns 404 if missing. You don’t need to pre-fs.accessit.- SSE keep-alives matter. Most proxies (nginx, Cloudflare) close idle connections after 30–60s. Send a
sse.comment("ping")every 25 seconds for long-lived streams.
See also
Section titled “See also”- Controllers — where
response.<helper>()is called from. - HTTP request — the other half of the handler signature.
- Middleware — short-circuit a request by returning a response from middleware.
Stream wire format, headers and synchronous producers
Section titled “Stream wire format, headers and synchronous producers”const sse = response.sse({ headers: { "Cache-Control": "private, no-store" }, maxQueuedBytes: 1024 * 1024, onOverflow: "disconnect",});sse.send(undefined, { status: "ready" }); // no event field: EventSource.onmessagesse.enqueue(undefined, "already serialized\nsecond line", undefined, { raw: true });The positional send(event, data, id?, options?) and write(...) calls still work.
An undefined event omits event:. Data is JSON by default; { raw: true } requires
string data and emits one safe data: line per input line. Event/id line breaks
and NUL are removed, so raw data cannot inject event fields.
Both sse({ headers }) and stream(contentType, { headers }) apply caller headers
before flushing. Cache-Control keeps the safe stream default unless explicitly
set in options.headers; a pre-existing global cache header does not override it.
Caller protocol headers are filtered case-insensitively before applying overrides.
Both helpers use chunked HTTP/1.1 framing, set their own content type, and preserve
Connection: close during graceful drain. Content-Length cannot override framing.
Use synchronous enqueue() for event-bus producers. It returns false when closed
or overflowing. The byte cap includes queued UTF-8 data and Node’s unsent buffer.
It defaults to 1 MiB for enqueue; onOverflow: "disconnect" aborts the signal and
runs disconnect cleanup, while "drop" rejects the newest chunk so the producer
can schedule a resync. Every drop increments controller.dropped, including drops
from send/write/render/comments. write() still returns false only when ended or
disconnected; true means ready to continue, so inspect dropped when using drop
mode. A single event exceeding the byte cap is always rejected (and disconnects
in the default overflow mode). Choosing maxQueuedBytes also bounds send/write/render and
SSE comments; using enqueue switches subsequent writes on that controller into
bounded mode. drain() waits for both queues. end() stops accepting new output
and flushes already accepted queued output before ending. Without a byte cap or
enqueue, send/write retain their existing behavior.
Graceful HTTP/1 drain tracks raw responses, including hijacked replies. Before headers flush it adds Connection: close; already-streaming replies finish and then have their idle connections reaped. A hand-written hijacked writer that sets its own Connection header must preserve close during drain rather than overwrite it with keep-alive. Headers already on the wire cannot be changed.
On HTTP/2, SSE and stream helpers omit connection-specific and Transfer-Encoding headers. Graceful drain sends GOAWAY and closes sessions once active streams finish; HTTP/1.1 keeps its existing Connection: close behavior. HTTP/1.0 uses close-delimited responses without Transfer-Encoding. The shutdown timeout still bounds a stream that never finishes.
http.serverOptions: { http2: true } uses session draining by default. The
framework maps idle-connection shutdown to session tracking on HTTP/2, so Fastify
never receives its unsupported forceCloseConnections: "idle" setting.
Format every JSON error response
Section titled “Format every JSON error response”http.errorMapper classifies thrown non-HttpError errors as { status, message?, payload? }. It does not see existing HttpError subclasses, returned validation errors, helper responses, unmatched routes, or global rate-limit failures.
Use http.errorResponse when the application needs one JSON error envelope across those paths:
import type { HttpConfigurations } from "@warlock.js/core";
export default { errorMapper: () => ({ status: 500 }), errorResponse({ status, requestId }) { return { error: { code: status >= 500 ? "serverError" : `http${status}`, message: status >= 500 ? "Internal server error." : "Request failed.", }, requestId, }; },} satisfies HttpConfigurations;The synchronous formatter receives HttpErrorResponseContext: { status: number, body: unknown, requestId: string }. Narrow body before reading fields; it is the parsed, serialized JSON body. Return a JSON-serializable object or undefined to keep the original. The formatter changes only the body, preserving the HTTP status and headers except for recalculated content length, a private, no-store cache policy, and the correlation header. If it throws or cannot serialize its result, core logs the formatter error and retains the original body.
The hook covers status 400–599 with application/json or an application/*+json content type, including Fastify parsing errors, unmatched-route 404s, validation 422s, rate-limit 429s, and 500s. Warlock responses use the ID already stamped from request.id. Before Warlock creates a Request, the formatter receives Fastify’s request.id and echoes that same value under http.requestId.header (default X-Request-Id). Setting http.requestId.enabled: false suppresses that header; without a stamped Warlock ID, the formatter falls back to Fastify’s request ID even for routed responses. Keep request-ID headers enabled when the body must correlate with Warlock request logs. HTML pages, files, raw text, streams, and errors after headers have been sent are not reformatted. With no formatter configured, existing response shapes are unchanged.