How It Works
Every get() or list() call runs entirely inside your process. Nuabase holds no state of its own: no database, no cache server, and no background job queue.
{ success, data }Schemas
Section titled “Schemas”Your Zod schema does two jobs. Nuabase converts it to JSON Schema with Zod 4’s z.toJSONSchema() to tell the model what shape to produce, and parses the model’s answer with the schema itself.
result.data is Zod’s parsed output, the same value schema.parse() would return:
.default()fills in a value the model left out..transform()runs, anddatahas the transformed type..refine()and.superRefine()checks apply. An answer that fails one is retried like any other invalid answer.- Unknown object keys are dropped, as
z.object()does by default. - String formats such as
.email(),.url(), and.uuid()are checked.
const Contact = z.object({ name: z.string(), email: z.email().optional(), phone: z.string().optional(), source: z.string().default('email-signature'),}).refine((c) => c.email || c.phone, 'Needs an email or a phone number');The JSON Schema describes what the schema accepts, its input side, so a field with a .default() is optional for the model, and a .transform() is described by the type it takes. Checks JSON Schema can’t express, such as a .refine(), aren’t shown to the model, so put the rule in your prompt as well if the model needs to know it.
Schemas with $refs, such as those that name a shared subschema with .meta({ id }), and recursive schemas work, including where the engine enforces the schema itself.
A few Zod types have no JSON Schema form, such as z.date(), z.bigint(), and z.map(). A call with one of these throws before anything is sent. Use z.iso.date() or z.iso.datetime() for dates, which arrive as strings, or add a .transform() to turn them into Date objects.
For list(), your schema is the shape of each row’s output. Nuabase asks for an array whose items each hold the primary key and your output:
array<{ <primaryKey>: string | number, <output.name>: <your schema> }>Prompt construction
Section titled “Prompt construction”Nuabase builds each request in this order:
- Your
prompt - Instructions that describe the output: a single JSON value named after
output.name, or forlist()a JSON array where each element echoes its input’s primary key - The JSON Schema, inside
<json-schema-spec>tags - Your
input, serialized as JSON inside<input-data>tags
The first three parts don’t change between calls with the same prompt and schema. Only the input does, which is what makes prompt caching work.
This is the exact text Pi, which has no schema flag, received on stdin for this list() call:
const Ticket = z.object({ category: z.enum(['bug', 'feature', 'question']), summary: z.string().optional(),});
await nua.list('Classify each support ticket', { input: [ { id: 't-1', body: 'Export button does nothing on Safari' }, { id: 't-2', body: 'Can you add dark mode?' }, ], primaryKey: 'id', output: { name: 'ticket', schema: Ticket },});Classify each support ticketYour task is to transform the provided <input-data> into a JSON array, that represents a list of ticket. The schema for this JSON array is defined below in <json-schema-spec> for you reference. It uses the JSON Schema spec. Respond with only the instance, not the schema. It should not be wrapped in any wrapper object -- return exactly what the JSON schema is expecting. Your entire response should be just the value of the JSON array containing objects with 'ticket' and its primary key 'id', as valid JSON. We will validate the result against the JSON schema, and we expect it to validate correctly. Note that the primary key of each element in the input-data is 'id'. In the output JSON array, each object should have the same key with the same value of the original data, so we can map the input element against the output element.<json-schema-spec> {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"array","items":{"type":"object","properties":{"id":{"type":["string","number"]},"ticket":{"type":"object","properties":{"category":{"type":"string","enum":["bug","feature","question"]},"summary":{"type":"string"}},"required":["category"]}},"required":["id","ticket"]}} </json-schema-spec><input-data>[{"id":"t-1","body":"Export button does nothing on Safari"},{"id":"t-2","body":"Can you add dark mode?"}]</input-data>Pi, on DeepSeek, answered in 1.4 seconds:
{ "success": true, "data": [ { "id": "t-1", "ticket": { "category": "bug", "summary": "Export button does nothing on Safari" } }, { "id": "t-2", "ticket": { "category": "feature", "summary": "Add dark mode" } } ], "usage": { "promptTokens": 437, "completionTokens": 133, "totalTokens": 570, "promptCacheReadTokens": 0, "promptCacheWriteTokens": 0 }, "model": "pi:deepseek-flash", "latencyMs": 1434, "source": "direct", "meta": { "engine": "local-agent", "agent": "pi", "model": "deepseek-flash", "costUsdEstimate": 0.0002907, "schemaEnforcement": "in-prompt" }}Schema enforcement
Section titled “Schema enforcement”Some engines can enforce a JSON Schema on the model’s output themselves. When the engine supports it, Nuabase passes the schema natively (schemaEnforcement: 'native'). Otherwise it describes the schema in the prompt and asks for raw JSON ('in-prompt').
On local agents, Claude Code always enforces natively, Codex does when the schema fits OpenAI’s strict mode, and Pi never does. If an engine rejects a native schema, Nuabase retries at once with the schema in the prompt. See Headless Coding Agent API.
Whichever path is used, Nuabase reads the JSON from the response, stripping a markdown code fence if the model added one, and parses it with your Zod schema.
Retries
Section titled “Retries”A call makes up to 3 attempts: the first, then retries after 1 and 2 seconds. Nuabase retries when another attempt could help:
- Transient network or provider errors
- Responses that aren’t valid JSON or that your schema rejects
It fails at once, without retrying, when another attempt can’t succeed:
- LLM provider APIs: an HTTP 401, 403, or 404 response, meaning a bad API key, no access, or an unknown model. Other errors, including 429 rate-limit and quota responses, are retried. Nuabase sets no timeout of its own on these requests.
- Coding agents: a call that runs past
timeoutMs, or a failure whose message Nuabase recognises as one that will repeat, such as not logged in, an authentication error, a usage or rate limit, quota or credit balance, a 401, 403, or 429, or a model that does not exist. The CLIs report failures only as free text, so a message Nuabase doesn’t recognise is retried. For example, Claude Code’s reply to a model name the account doesn’t have, “There’s an issue with the selected model”, is retried twice before the call fails, about 10 seconds in all. - Before any attempt: a model the client can’t route to, such as a provider model on a coding agent, is refused with
attempts: 0and nothing is sent.
When every attempt fails, the result’s reason says why the last one failed and attempts says how many were made. latencyMs covers all attempts. Retries are logged as warnings to the local agent’s logger, or to the console.
Here a .refine() the model can’t see (JSON Schema has no way to express it) rejects every answer, on Claude Code with haiku:
const Code = z.object({ code: z.string() }) .refine((v) => v.code === 'XK-4471-Q', 'code must be the internal promo code');
const result = await nua.get('Suggest a promo code for a spring sale', { output: { name: 'promo', schema: Code },});[WARN] LLM call attempt failed; retrying { attempt: 1, delayMs: 1000, error: 'Validation failed: ✖ code must be the internal promo code\n' + '\n' + 'Raw LLM response:\n' + '{"value":{"code":"SPRING26"}}'}[WARN] LLM call attempt failed; retrying { attempt: 2, delayMs: 2000, error: 'Validation failed: ✖ code must be the internal promo code\n' + …}The [INFO] start and completion lines for each attempt are left out above. The result:
{ "success": false, "error": "LLM call failed after 3 attempts. Last error: Validation failed: ✖ code must be the internal promo code\n\nRaw LLM response:\n{\"value\":{\"code\":\"SPRING26\"}}", "reason": "Validation failed: ✖ code must be the internal promo code\n\nRaw LLM response:\n{\"value\":{\"code\":\"SPRING26\"}}", "attempts": 3, "source": "direct", "latencyMs": 12982}Prompt caching
Section titled “Prompt caching”LLM providers cache the start of a request. When the next request begins with the same text, the cached part is billed at a large discount (often around 90% off) and processed faster. Because Nuabase puts your prompt and schema before the input, calls that share a prompt and schema share a cacheable prefix.
- Do: keep the
promptstring identical across calls, and pass everything that varies throughinput. - Avoid: interpolating changing values into the prompt, such as
`Classify ${ticket.title}`. A changing prompt misses the cache on every call, and on Claude it also pays a 25% premium to write a new cache entry each time.
result.usage.promptCacheReadTokens and promptCacheWriteTokens show what happened, when the provider reports them. These are two calls in a row on Claude Code with haiku, sharing a prompt and schema and differing only in input:
const prompt = 'Classify the support ticket into one of: bug, feature, question.';const output = { name: 'category', schema: z.enum(['bug', 'feature', 'question']) };
await nua.get(prompt, { input: { body: 'Export button does nothing on Safari' }, output });await nua.get(prompt, { input: { body: 'Can you add dark mode?' }, output });call 1 "bug" promptTokens 1673 cacheRead 0 cacheWrite 1671call 2 "feature" promptTokens 1667 cacheRead 520 cacheWrite 1145On Claude Code, the second call read 520 tokens from the cache. Most of the request was still written to the cache again. Read the numbers your own engine reports rather than assuming a hit rate.
Runtimes
Section titled “Runtimes”The main nuabase entry point imports no Node.js built-ins, so a client configured with providers runs in Node.js and in edge runtimes such as Cloudflare Workers and Vercel Edge. Only nuabase/local-agent, which starts CLI processes, needs Node.js.
See Development for the repository layout, tests, and release process.