Skip to content
Mayura

Get started

Quickstart

Create a Mayura project in one command, or add a first agent to your own project and run it offline or on a real model.

There are two ways to start: let the CLI create a complete project for you, or add Mayura to a project you already have. Both take a few minutes.

Option 1: create a project

bash
npx mayura init

The wizard asks three things:

  1. A starter or a template. Starters are complete projects with tests: four are servers with a worker, a UI and deployment files, and one (cli-agent) is an assistant you run in your terminal. Templates are single files that show one feature.
  2. A model provider. Offline (no key needed; rule-based stand-in models), OpenAI, Anthropic, an OpenAI-compatible provider such as Groq, Gemini, Mistral, DeepSeek, xAI, OpenRouter, Together or Fireworks, Azure OpenAI, or any other compatible endpoint.
  3. Your API key, prices and spending caps, for a real provider. The key is typed masked and written only to the new project's .env, which git ignores.

It shows the plan, writes the files when you confirm, and prints the next steps. For a starter those are:

bash
cd my-agent
npm install
npm run dev

npm run dev runs mayura dev: it builds the project, starts it, and rebuilds and restarts whenever you save. See mayura init for every starter and template.

Option 2: add Mayura to your project

Install Mayura. It includes z (Zod) for schemas, so there is nothing else to add:

bash
npm install mayura

Mayura is an ES module package, so your project needs "type": "module" in package.json (or .mts files).

A first agent, offline

This agent uses a tool to add two numbers. It runs on a scripted model that replays fixed responses, so it needs no API key and no network. It's the same way you test agents.

ts
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 agent = defineAgent({
  id: 'calculator', version: '1', instructions: 'Use the addition tool to answer.',
  input: z.object({ request: z.string() }), output: z.object({ answer: z.number() }), tools: [add],
  model: scriptedModel([
    { type: 'tool_calls', calls: [{ id: 'add-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 result = await runtime.submit(agent, { input: { request: 'Add 2 and 3.' } }).result();
  console.log(result); // { status: 'succeeded', output: { answer: 5 } }
} finally {
  await runtime.close();
}

Save it as agent.ts and run it. Node.js 24 runs TypeScript directly; on Node.js 22, use npx tsx agent.ts or compile with tsc.

bash
node agent.ts

Four things happened:

  • defineTool declared a tool: schemas for its input and output, what kind of effect it has (none, read, write or host), and the function that does the work. Mayura validates the input before calling it and the output after.
  • defineAgent declared an agent: instructions, schemas, tools and a model.
  • createRuntime created the runtime that runs agents, with an explicit allow-list. The agent can use math.add only because tool:math.add is granted; remove it and the run is blocked.
  • runtime.submit started a run and result() waited for its outcome. Always check result.status before reading result.output: a run can also end failed, blocked, cancelled or outcome_unknown. See Outcomes.

Use a real model

Swap the scripted model for a provider. A real model needs your model's prices and cost caps: a run is allowed to spend nothing until you set limits.maxCostMicros. Costs are in micros, millionths of a dollar.

ts
import { createRuntime, defineAgent, defineTool, z } from 'mayura';
import { anthropicMessages } from 'mayura/provider-anthropic';

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 agent = defineAgent({
  id: 'calculator', version: '1', instructions: 'Use the addition tool to answer.',
  input: z.object({ request: z.string() }), output: z.object({ answer: z.number() }), tools: [add],
  model: anthropicMessages({
    apiKey: process.env.ANTHROPIC_API_KEY!, model: 'claude-sonnet-5',
    pricing: { inputMicrosPerMillionTokens: 3_000_000, outputMicrosPerMillionTokens: 15_000_000 }, // check your model's prices
    maxCostMicros: 50_000, // at most 5 cents per model call
  }),
});

const runtime = createRuntime({
  profile: 'ephemeral',
  permissions: { allow: ['model:anthropic.messages', 'tool:math.add'] },
  limits: { maxCostMicros: 200_000 }, // at most 20 cents per run
});

The rest of the program is unchanged. The provider needs the tool's input and the agent's output as JSON Schema; Mayura generates both from your Zod schemas (Zod 4.2 or later). Providers accept only strict schemas, where every field is present, so use .nullable() rather than .optional() for a field the model may leave empty. defineAgent checks this and tells you exactly which field to change.

OpenAI (openAIResponses) and OpenAI-compatible providers (openAICompatibleChat) work the same way; each is granted as model: followed by its adapter id. See Model providers. Mayura never reads API keys from the environment by itself: you pass them in, so where they come from is up to you.

Watch a run

A run reports what it's doing as events. observe() returns them as an async iterable, and runtime.inspect shows what the run has spent:

ts
const run = runtime.submit(agent, { input: { request: 'Add 2 and 3.' } });
for await (const event of run.observe()) {
  if (event.type === 'tool.started') console.log('using', event.metadata.toolId);
}
const result = await run.result();
console.log(runtime.inspect(run).budget.spentMicros);

Events carry metadata such as ids, steps, statuses and costs, not your prompts or tool inputs and outputs. The one exception is output.delta, which carries streamed answer text (below).

Stream the answer

To show an answer while it's being written, give the agent a stream policy naming the output field to stream, and print the output.delta events:

ts
import { defineAgent } from 'mayura';

const assistant = defineAgent({
  id: 'assistant', version: '1', instructions: 'Answer helpfully.',
  input, output, tools: [], model,
  stream: { field: ['reply'], guards: [] }, // stream output.reply
});

const run = runtime.submit(assistant, { input: { question: 'What is Mayura?' } });
for await (const event of run.observe()) {
  if (event.type === 'output.delta') process.stdout.write(String(event.metadata.text));
}

The final result() is still validated against the output schema; the streamed text is a preview. See Streaming.

Next steps