Zod 4 toJSONSchema to Sume output_schema: nullable, not optional
z.toJSONSchema works for a Sume Format output_schema if you use nullable instead of optional. A tested table of what passes and what the validator rejects.

Zod 4's built-in z.toJSONSchema() produces a schema Sume accepts as a Format run's output_schema, with one rule to follow: use .nullable() where you would normally write .optional(). An optional field is left out of required, and Sume's strict subset rejects any property that is not listed there. Keep the default io mode, which emits additionalProperties: false on every object, and point your media fields at SumeMediaFile#.
I generated schemas with Zod 4.4.3 and ran them through the strict-subset validator in Sume's repository, so the pass and fail results below are tested rather than assumed.
Which Zod choices pass, and which fail?
Zod's docs say the default io is "output", that z.object() emits additionalProperties: false in that mode, and that input mode omits it. That detail decides whether your schema is valid: the same object converted with io: "input" failed with additional_properties_false. Nothing else in the shape differs, so a copied option from another tutorial is enough to break it.
| Zod code | Result | Why |
|---|---|---|
z.string().nullable() | Pass | Emitted as anyOf of string and null, listed in required |
z.string().optional() | Fail: required_completeness | Property missing from required |
z.toJSONSchema(s, { io: "input" }) | Fail: additional_properties_false | Input mode drops the flag on objects |
z.string().default("hi") | Pass | Listed in required, with a default keyword |
z.union([A, B]) | Pass | Emitted as anyOf |
z.discriminatedUnion("kind", [A, B]) | Fail: unsupported_keyword | Emitted as oneOf, which Sume rejects |
z.number().int(), .min(), .max() | Pass | Range keywords accepted |
z.object({...}).passthrough() | Fail: additional_properties_false | Extra keys are allowed by design |
What does a working script look like?
The Media field is a z.any() carrying a $ref through .meta(), which Zod copies into the output unchanged. The leading $schema key is stripped before sending, since the request does not need it. The printed body has the shape creating a run takes, so send it with POST /v1/formats/{handle}/{slug}/runs, an Idempotency-Key and your key as a bearer token.
import * as z from "zod"; // zod 4: z.toJSONSchema is built in
const Media = z.any().meta({ $ref: "SumeMediaFile#" }); // Sume's built-in media shape
const Promo = z.object({
headline: z.string(),
subtitle: z.string().nullable(), // .optional() would be left out of "required"
tags: z.array(z.string()).max(5),
hero: Media,
});
const { $schema, ...schema } = z.toJSONSchema(Promo); // default io: "output"
const body = {
instruction: "Write the promo and render the hero image.",
input: { product_name: "Aurora Headphones" },
output_schema: { name: "acme/promo/v1", strict: true, schema },
};
console.log(JSON.stringify(body, null, 2));What about the input side of the run?
Zod is just as useful on the data you receive. A run's result carries your schema's fields, so Promo.parse(result) gives you a typed object, and a mismatch throws in your code instead of surfacing three functions later. The conversion above and the parse use the same object, which is the whole point: the schema you send and the type you read cannot disagree.
Be careful with .nullable() on the parsing side. A value of null is Sume's way of saying the run had nothing to put there, so treat it as data, not as an error. Handle it where you render the result: show a fallback caption for a null subtitle, rather than throwing, and log the run so you can see how often the case occurs. If a null is never acceptable for a field, make that field required and non-null in the schema, and let a failed run surface it.
- Replace
.optional()with.nullable()everywhere, including inside arrays of objects. - Do not pass
io: "input"; leave the default. - Avoid
discriminatedUnion; usez.unionand akindliteral you branch on. - Avoid
.passthrough(), which allows extra keys. - Print the schema in a test and compare it with a stored copy.
What else can break the schema?
Sume's docs list limits that Zod will not warn about: ten levels of nesting, 5000 properties in total, and 120,000 characters of strings across the whole document. Long .describe() text counts toward that last one. A $ref other than #/$defs/* or SumeMediaFile# is rejected, and strict: false does not relax anything, so do not try it as a way out.
If a schema is rejected, the 400 lists every violation as a path, a stable rule token and a message, so one failed call is usually enough to fix all of them. Zod types with no JSON Schema form, such as dates, transforms and bigint, make toJSONSchema throw by default according to Zod's docs, so model a date as a string and convert after parsing.
You do not need Sume to catch most of these. A short CI test can walk the generated schema and assert, for every node with type: "object", that additionalProperties is false and that the required list holds exactly the keys of properties. Those two checks cover the two rules that trip up most schemas ported from elsewhere, and they fail in seconds on a laptop instead of on a paid run. Add a third check that no node contains oneOf or allOf, and the common rejections are covered before the first request.
The TypeScript SDK documents helpers for waiting on a run, and nothing there changes how the schema is written. Generate it, send it, then parse the result with the same Zod object.
Sources
Related posts
- Sume Format structured output: typed JSON from a JSON Schema
- Optional field in a Sume Format output_schema: use a null union
- JSON Schema oneOf not supported in strict mode: fix the violations
- Pydantic model to Sume output_schema: extra forbid, no defaults
- Sume output_schema anyOf: two result shapes without oneOf
More in Developers
- Which MCP server lets Claude Code or Cursor generate video and images?
MCP servers that let Claude Code and Cursor make video and images: Sume, fal, Replicate, Runway, Higgsfield. Endpoints, sign-in, billing, setup.
- Idempotency keys for AI video APIs: retry without paying twice
An idempotency key makes a retried create return the original run or job instead of a second paid one. How Sume's Idempotency-Key works on each API.
- Signed webhooks for Sume video runs: events, retries, verification
Sume sends one HMAC-SHA256 signed POST when a Format, Action, or Agent Completion run completes or fails. Verify the raw body and dedupe on request_id.
- Spend caps for unattended AI agents: how Sume bounds each run
An unattended agent has no one to approve spend, so Sume caps generation per run: required on Agent Completions, and up to $500 on Format runs.
Written by Sume