Concepts
Agents
Define an agent: instructions, a model, typed tools and typed input and output, checked once and reused for every run.
An agent in Mayura is a definition: the instructions a model follows, the model adapter it calls, the tools it may
use, and the shape of its input and output. defineAgent checks the definition and freezes it. Nothing runs, no
connection opens and no tool is called until you submit the agent to a runtime. You define an agent
once, at module load, and submit it as many times as you like.
A complete agent
This example runs offline. scriptedModel from mayura/testing is a test model that replays the responses you give
it, so you can see the whole loop without an API key. It does not read the instructions or reason.
import { createRuntime, defineAgent, defineTool, z } from 'mayura';
import { scriptedModel } from 'mayura/testing';
const add = defineTool({
id: 'math.add',
version: '1',
description: 'Add two numbers.',
input: z.object({ left: z.number(), right: z.number() }),
output: z.object({ sum: z.number() }),
effects: 'none',
capabilities: [],
execute: ({ left, right }) => ({ sum: left + right }),
});
const calculator = defineAgent({
id: 'calculator',
version: '1',
instructions: 'Use math.add to answer the question.',
input: z.object({ question: z.string() }),
output: z.object({ answer: z.number() }),
tools: [add],
model: scriptedModel([
{ type: 'tool_calls', calls: [{ id: 'call-1', toolId: 'math.add', input: { left: 2, right: 3 } }], usage: { costMicros: 0 } },
{ type: 'final', output: { answer: 5 }, usage: { costMicros: 0 } },
]),
});
const runtime = createRuntime({
profile: 'ephemeral',
permissions: { allow: ['model:scripted', 'tool:math.add'] },
});
try {
const outcome = await runtime.submit(calculator, { input: { question: 'What is 2 + 3?' } }).result();
if (outcome.status === 'succeeded') console.log(outcome.output.answer); // 5, typed as number
else console.error(outcome.status, outcome.error.code);
} finally {
await runtime.close();
}The same file is in the repository as examples/first-agent.mjs.
Options
| Option | Required | What it is |
|---|---|---|
id |
yes | Stable name of the agent, for example support.assistant. |
version |
yes | Version of this definition, for example 1 or 2.1.0. |
instructions |
yes | The system instructions sent to the model on every call. At most 64 KiB. |
model |
yes | A model adapter, for example openAIResponses(...) from mayura/provider-openai. |
tools |
yes | The tools this agent may call. Pass [] for none. At most 256, with unique ids. |
input |
yes | Schema for the input you submit. |
output |
yes | Schema for the final answer. |
guards |
no | { input, output } lists of guards that can allow, block or rewrite content. At most 32 per list. |
hooks |
no | Lifecycle hooks created with defineHook. |
stream |
no | Stream one text field of the final answer while it is written. |
outputJsonSchema |
no | The output as JSON Schema for model providers. Generated from output when the validator can describe itself (Zod 4.2 and later). |
defineAgent throws a MayuraError with code INVALID_CONFIG if anything is wrong, for example a duplicate tool id,
a model that cannot call tools while tools is not empty, or a tool or output schema the model provider would refuse.
The message says what to fix.
Ids and versions
id and version must start with a letter or digit and may contain letters, digits, ., _, / and -, up to 128
characters. The id appears in run events, in runtime.inspect() and in durable records, so treat it as a stable name:
don't rename an agent in production without a reason. Change the version when the behavior changes, for example new
instructions, a new tool or a changed output schema, so records made by the old definition stay distinguishable.
Instructions
instructions is a plain string sent as the system prompt. It guides the model; it does not grant or restrict
anything. What the agent is actually allowed to do comes from the runtime's permissions, so a
prompt injection in the user's input or in a tool result cannot give the agent new authority. Don't put secrets in
instructions.
Input and output schemas
input and output accept any Standard Schema validator. Zod is the reference
validator and the one used throughout these docs. TypeScript infers the types from the schemas: submit checks the
input you pass, and a successful outcome's output has the output type.
Mayura validates at every boundary:
- The submitted input is checked before the model is called. Invalid input ends the run with
INVALID_INPUT. - The model's final answer is checked against
outputbefore it is released. An invalid answer ends the run withINVALID_OUTPUT; a run never reports success with an output that does not match.
Values must be plain JSON: strings, finite numbers, booleans, null, arrays and plain objects. Dates, class instances,
undefined and bigint are rejected. Size limits come from the runtime (maxInputBytes, maxOutputBytes).
Real model providers also need the output shape as JSON Schema. Mayura generates it from output (validators that
implement Standard JSON Schema can describe themselves, as Zod 4.2 and later do) and sends it with every model call;
you can see it as agent.outputJsonSchema. Providers accept only strict schemas, so use .nullable() rather than
.optional() for fields the model may leave empty; defineAgent checks this with the model you give it. Pass
outputJsonSchema yourself only when your validator cannot describe itself. The validator is still what Mayura
enforces on the answer.
The model
model is a model adapter: an object with an id, its capabilities, a per-call cost ceiling (maxCostMicros) and a
generate function. Mayura never picks a provider for you. The adapter's id is what you allow in the runtime, as
model:<adapter id>: model:openai.responses, model:anthropic.messages, model:scripted.
import { defineAgent, z } from 'mayura';
import { openAIResponses } from 'mayura/provider-openai';
const answerer = defineAgent({
id: 'answerer',
version: '1',
instructions: 'Answer the question in one or two sentences.',
input: z.object({ question: z.string().min(1).max(2_000) }),
output: z.object({ answer: z.string() }),
tools: [],
model: openAIResponses({
apiKey: process.env.OPENAI_API_KEY ?? '',
model: 'gpt-5-mini',
maxCostMicros: 20_000, // at most $0.02 per model call
pricing: { inputMicrosPerMillionTokens: 250_000, outputMicrosPerMillionTokens: 2_000_000 },
timeoutMs: 30_000,
}),
});Use your model's real prices; the numbers above are placeholders. A paid model also needs a run cost limit on the runtime, because the default is 0: see Costs and budgets. Providers, routing between them and fallbacks are covered in Model providers and Model routing.
Tools
tools is the complete list of tools this agent can call. The model only sees these tools, and a request for any
other tool id ends the run with NOT_FOUND. Listing a tool does not permit it: the runtime must also allow
tool:<id> and the tool's other requirements. See Tools and Permissions.
How a run works
When you submit an agent, the runtime loops over steps until the model gives a final answer or a limit is reached:
- Validate the input against
input, then run the input guards. - Check that
model:<adapter id>is allowed and that the run's call and cost limits leave room for one more model call. The model call'smaxCostMicrosis reserved from the run budget before the call. - Call the model with the instructions, the conversation so far and the tool list (id, description and
inputJsonSchemaof each tool). Its actual cost is charged. - If the model asks for tools, check every requested call first: the tool is registered, allowed, and its input matches its schema, and the total cost of the batch fits the budget. Then run the calls one at a time. Each result is validated against the tool's output schema and passed through the agent's output guards before the model sees it. If any call does not succeed, the run ends with that call's outcome.
- Go back to step 2 with the tool results added to the conversation.
- If the model gives a final answer, validate it against
output, run the output guards and return it.
If maxSteps (default 16) is reached without a final answer, the run fails with LIMIT_EXCEEDED. Every run ends
with one of the outcomes described in Outcomes and errors.
Guards, hooks and streaming
- Guards check content at the edges of a run. Input guards see the submitted input; output guards see the final
answer and every tool result before the model does. A guard can allow, block (the run ends as
blocked) or rewrite, for example to redact personal data. See Guardrails. - Hooks run your code at points in the run's life, such as before each tool call or after the run finishes, and can stop the run. See Lifecycle hooks.
- Streaming releases one string field of the final answer in small checked batches while the model writes it.
Without
stream, nothing is released until the whole answer has passed validation and guards. See Streaming.
Good to know
- A definition is immutable. To change an agent, define a new one (usually with a new version).
- Tools run in your process as ordinary JavaScript. Declaring
effects: 'none'is a promise you make, not a sandbox. - An agent can call another agent as a tool, with narrower permissions. See Child agents.
- In tests,
scriptedModellets you script the exact tool calls and answers. See Testing.