Imported from tunnckoCore/zagora (
.opencode/AGENTS.md). Install upstream withnpx skills add tunnckoCore/zagora --skill .opencode. Copyright stays with the author.
Zagora Library
Zagora enables building type-safe and error-safe procedures that encapsulate business logic with robust validation, error handling, and context management. Agents are callable functions that ensure input/output safety and provide structured error responses.
Highlights
- 🪶 Minimal: Lightweight and focused, built on StandardSchema for seamless validation.
- 🛡️ Error-Safe: Eliminates exceptions - always
{ ok, data, error }for predictable, crash-free execution. - 🦢 Graceful: Functions never throw or disrupt your process, akin to
effect.tsandneverthrow. - 📝 Typed Errors: Define error schemas for strongly-typed error helpers, enhancing handler reliability.
- 🧹 Clean Error Model: Three distinct error types - unknown, validation, and user-defined—for clarity.
- 🔒 Type-Safe: Full type inference across inputs, outputs, errors, context, optionals, and defaults.
- ✋ Ergonomic: Pure functions with auto-filled defaults, optional args, and detailed diagnostics.
- 🏠 Familiar: Echoes remote-RPC patterns from oRPC and tRPC, but focused on libraries, not apps.
- ⚖️ Unopinionated: Zero assumptions - no routers, middlewares, or network dependencies.
- 🎁 No Unwrapping: Direct access to results, unlike
neverthrow- no extra steps required.
Usage
import { z } from 'zod';
import { zagora } from 'zagora';
const za = zagora();
const getUser = za
.input(z.tuple([
z.string(),
z.number().default(18),
]))
.output(z.object({ name: z.string(), age: z.number(), email: z.string() }))
.handler(async (_, name, age) => {
// name: string;
// age: number; -- even if not passed!
return { name, age, email: `${name.toLowerCase()}@example.com` };
})
.callable();
const result = await getUser('Charlie');
if (result.ok) {
console.log(result.data);
// ^ { name: 'Charlie', age: 18, email: 'charlie@example.com' }
} else {
console.error(result.error);
// ^ { kind: 'UNKNOWN_ERROR', message, cause }
// or
// ^ { kind: 'VALIDATION_ERROR', message, issues: Schema.Issue[] }
}
// primitive input
const helloUppercased = za
.input(z.string())
.handler((_, str) => str.toUpperCase())
.callable();
const res = helloUppercased('Hello world');
if (res.ok) {
console.log(res);
// ^ { ok: true, data: 'HELLO WORLD', error: undefined }
}
// array input
const uppercase = zagora({ autoCallable: true, disableOptions: true })
.input(z.array(z.string()))
.handler((arrayOfStrings) => {
// NOTE: `x` is typed as string too!
return arrayOfStrings.map((x) => x.toUpperCase());
})
const upRes = uppercase(['foo', 'bar', 'qux']);
if (upRes.ok) {
console.log(upRes);
// ^ { ok: true, data: ['FOO', 'BAR', 'QUX' ] }
}
You'll also have access to all the types, utils, and error-related stuff through package exports.
import {
isValidationError,
isInternalError,
isDefinedError,
isZagoraError,
} from 'zagora/errors';
import * as ZagoraTypes from 'zagora/types';
import * as zagoraUtils from 'zagora/utils';
Creating procedures
Fluent builder API for chaining methods on a Zagora instance:
import { zagora } from 'zagora';
import z from 'zod';
const agent = zagora()
.input(z.object({ name: z.string(), age: z.number().default(20) }))
.output(z.object({ greeting: z.string() }))
.handler(({ context }, input) => ({
greeting: `Hello ${input.name}, you are ${input.age} years old!`
}))
.callable(/* { context, cache, env } */);
const result = agent({ name: 'Alice' });
Important: the handler signature differs from oRPC/tRPC and Zagora requires .callable by default:
- oRPC/tRPC -
.handler(({ input, context }) => {})- always a single object - zagora with primitive input (string, object, array) -
.handler(({ context }, input) => {}) - zagora with tuple schemas (spreaded args) -
.handler(({ context }, name, age) => {}) - zagora with errors map -
.errors({ NOT_FOUND: z.object({ id: z.string() })}).handler(({ context, errors }, name, age) => {}) - zagora without options object -
zagora({ disableOptions: true }).input(z.string()).handler((input) => input)
Input and Output Validation
Define schemas for type-safe inputs and outputs using Zod, Valibot, or any Standard Schema V1 compliant library:
- Input Schema: Validates arguments before execution.
- Output Schema: Ensures return values match expectations.
const mathAgent = zagora()
.input(z.tuple([z.number(), z.number()]))
.output(z.number())
.handler((_, a, b) => a + b)
.callable();
const sum = mathAgent(5, 10); // { ok: true, data: 15 }
Env Vars validation
Define schemas for type-safe environment variables with Zod, Valibot, or any Standard Schema V1 compliant library. Env vars are passed to the handler's options object as options.env, not in options.context or somewhere else. All default filling, optionals, coercing works as in any other place. Though, in theory you can provide whatever you want in options.context including env vars, if you want to match the behavior of oRPC or something else.
Important: Providing async schema for env variables is not supported, at least for now.
const safeApi = zagora()
.env(z.object({
DATABASE_URL: z.string().min(1).default('file://db.sqlite'),
JWT_SECRET: z.string().min(10),
PORT: z.coerce.number() // env.PORT will be number
}))
.input(z.tuple([z.number(), z.number()]))
.output(z.number())
.handler(({ env }) => {
// env: { DATABASE_URL: string, JWT_SECRET: string, PORT: number }
return a + b + env.PORT;
})
// NOTE: you may need to cast with `as any` beause process.env differs from the schema!
.callable({ env: process.env });
// PORT is coming from env vars
const sum = safeApi(5, 10); // { ok: true, data: 15 + PORT }
Important notes:
- When
disableOptionsis enabled (eg.true) then handler WILL NOT have access to type-safe env vars. - When
autoCallableis enabled (eg.true) make sure to provide the runtime env vars as second argument to the.env(schema, processEnvOrImportMetaEnv)method. - Async schema validation is not supported, for now
- the passed runtime env vars must match the provided schema (on type-level), thus you may need to cast to
as anywhen you are providingprocess.envorimport.meta.env. That is intentional because we want to be able to warn you (typescript report you) if you manually providing them. - in case
env: process.envis wanted, then just make the schema likez.union([z.object(), z.record(z.string(), z.string())])and you will not need to case withas anyat.callable.
Error Handling
Define custom errors with schemas for structured error responses:
const apiAgent = zagora()
.input(z.object({ id: z.string() }))
.output(z.object({ data: z.any() }))
.errors({
NOT_FOUND: z.object({ message: z.string() }),
UNAUTHORIZED: z.object({ userId: z.string() })
})
.handler(({ errors }, { id }) => {
if (!id) throw errors.UNAUTHORIZED({ userId: 'unknown' });
// ... logic
if (!found) throw errors.NOT_FOUND({ message: 'Item not found' });
return { data: item };
})
.callable();
Procedures return ZagoraResult<TOutput, TErrors> with ok: true for success or ok: false with typed errors.
Context Management
Pass shared data like databases or user info via context:
const dbAgent = zagora()
.context({ db: myDatabase })
.input(z.string())
.output(z.any())
.handler(({ context }, query) => {
console.log(context.bar); // => 123
return context.db.query(query);
})
.callable({ context: { bar: 123 }});
Override context per call: agent.callable({ context: { db: testDb } })
Caching and Memoization
Add caching to avoid redundant computations:
const cache = new Map();
const cachedCall = zagora()
.cache(cache)
.input(z.string())
.output(z.string())
.handler((_, input) => expensiveOperation(input))
.callable();
// first time called
cachedCall('foo');
// second is cache hit
cachedCall('foo');
Cache can also be passed at execution-site (server handlers) through .callable({ cache }).
Cleaner API - auto callable and disable options
For simpler procedures and API look, enable auto-callable mode to skip .callable() and disable passing options to handler:
const simpleProcedure = zagora({ autoCallable: true, disableOptions: true })
.input(z.tuple([z.string(), z.number().default(10)]))
.output(z.string())
.handler((str, num) => str.toUpperCase());
const result = simpleProcedure('hello'); // Direct call
Async procedures
Async handlers for I/O operations:
const asyncAgent = zagora()
.input(z.string())
.output(z.object({ result: z.string() }))
.handler(async (_, url) => {
const response = await fetch(url);
return { result: await response.text() };
})
.callable();
Best Practices
- Use descriptive schemas for clarity.
- Define errors for all failure cases.
- Leverage context for dependencies.
- Enable caching for performance-critical agents.
- Test agents with various inputs and error scenarios.
Agents built with Zagora are composable, testable, and maintain type safety throughout the application lifecycle.
Rules and Special Notes for Zagora usage
The following rules outlines critical points, edge cases, and things to be careful about when using Zagora. These are derived from specially noted sections, examples, and warnings in the documentation.
Error Handling Cautions
Uppercase Error Keys
- Caution: All keys in the error map must be uppercased (e.g.,
NOT_FOUND, notnot_found). TypeScript will report a type error if not. - Why: These keys represent error "kinds" and are used in
result.error.kind.
Error Helper Validation
- Caution: If you pass invalid or missing keys to error helpers (e.g.,
errors.NOT_FOUND({ invalidKey: 'value' })), you get aVALIDATION_ERRORwith akeyproperty indicating which error validation failed. - Example:
throw errors.RATE_LIMIT({ retryAfter: 'invalid' })→VALIDATION_ERRORbecauseretryAfterexpects a number. - Tip: Use
.strict()on error schemas to throw on unknown keys:z.object({...}).strict().
Error Type Guards
- Caution: Use
isValidationError,isInternalError,isDefinedError,isZagoraErrorto narrow error types safely. - Note: Even syntax errors in handlers return
ZagoraResultwith error, never crashing the process.
Context Merging and Management
- Caution: Initial context (from
.context()) is deep-merged with runtime context (from.callable({ context })). - Example:
.context({ userId: 'default' })+.callable({ context: { foo: 'bar' } })→ merged{ userId: 'default', foo: 'bar' }. - Tip: Useful for dependency injection; override at execution site (e.g., in server handlers).
Input/Output Validation
Tuple Inputs (Multiple Arguments)
- Caution: Complex feature; schemas like
z.tuple([z.string(), z.number().default(18)])spread to handler args with defaults/optionals applied. - Example: Handler receives
(name, age)whereageisnumber(notnumber | undefined) due to default. - Tip: Supports per-argument validation and diagnostics; missing required args cause
VALIDATION_ERROR.
Default Values
- Caution: Defaults work at any schema level (objects, tuples, primitives); handler gets fully populated args.
- Example:
z.number().default(10)→ no need to pass; handler seesnumber, notnumber | undefined.
Async Support
Async Schemas
- Caution: If input/output/error schemas are async (e.g.,
z.string().refine(async (val) => ...), procedure signature remains sync (ZagoraResult), but you must await at callsite. TypeScript may warn "may not need await" – ignore and await. - Why: StandardSchema limitation; cannot infer async on type-level.
- Tip: ArkType doesn't support async schemas, avoiding this issue.
Handler Async Behavior
- Caution: Sync handler → sync procedure; async handler or Promise-returning → async procedure (
Promise<ZagoraResult>). - Note: Cache async methods force procedure async.
Caching/Memoization
Cache Key Composition
- Caution: Cache key includes input, input/output/error schemas, and handler function body. Changes to any invalidate cache.
- Tip: Useful for custom strategies; memoization out-of-the-box.
Cache Failures
- Caution: Cache adapter throws →
UNKNOWN_ERRORwithcauseset to original error; process never crashes. - Future: May change to
CACHE_ERROR. - Tip: If cache has async methods (e.g.,
hasis async), procedure becomes async – await despite TypeScript warnings.
Cache Provision
- Caution: Provide cache via
.cache()(definition) or.callable({ cache })(execution). Execution-site useful for routers/server handlers.
Options and Configuration
Options Object
- Caution: Handlers receive
optionsas first param:{ context, errors }. Typed and merged. - Example:
handler((options, input) => { const { context, errors } = options; ... }).
Disable Options
- Caution:
zagora({ disableOptions: true })omits options; handler starts directly with inputs. - Example:
handler((input) => ...)instead ofhandler((options, input) => ...).
Auto-Callable Mode
- Caution:
zagora({ autoCallable: true })returns procedure directly from.handler(); skip.callable(). - Tip: Combine with
disableOptionsfor cleaner APIs.
Guarantees and Type Safety
Never-Throwing
- Caution: Procedures never throw; all errors (validation, handler, cache) wrapped in
ZagoraResult. - Example:
throw new Error('Oops')→result.error.kind === 'UNKNOWN_ERROR',result.error.cause.message === 'Oops'.
Type Inference
- Caution: Full TS support;
result.ok,result.data,result.errorare discriminated unions. - Note: Complex type system tested; changes caught by type tests.
General Tips
- Motivation Reminder: Zagora produces "just functions" – no network/router assumptions. Focused on low-level, library-building.
- Comparison: Unlike oRPC/tRPC (network-focused, always async, single-object inputs), Zagora supports sync, tuples, no middlewares.
- Alternatives: Over plain TS (no runtime validation); over standalone schemas (ergonomic layer, unified validation).
- Testing: Inspect
test/types-testing.test.tsfor type guarantees. - Edge Cases: Always test with invalid inputs, async paths, and error scenarios.
By heeding these cautions, you can avoid common pitfalls and leverage Zagora's full potential for type-safe, error-safe procedures.