bun add oyadotai zodThe API mirrors Mastra (createTool + Agent.generate):
import { Agent, createTool } from "oyadotai";
import { anthropic } from "oyadotai/anthropic";
import { z } from "zod";
const getWeather = createTool({
id: "get_weather",
description: "Look up the current weather for a city",
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => fetchWeather(city),
});
const agent = new Agent({
name: "WeatherBot",
instructions: "You are a helpful weather assistant.",
model: anthropic("claude-haiku-4-5-20251001"),
tools: { get_weather: getWeather },
});
const { text } = await agent.generate("How's the weather in NYC?");
console.log(text);Types are inferred from the zod schema. Every value defaults to OPAQUE, so tool
outputs never reach the model - you write zero projection annotations. Migrating
from Mastra is just changing the imports (see the README).
There's also an oya-native form - skill({ name, input, run }) and
agent.run(prompt) returning the projected outputs - if you prefer it.
The rest of this page covers the low-level runtime underneath
Agent- reach for it only to inspect or hand-build a plan.
- A planner model emits a typed dataflow Plan IR - a DAG of named, typed handles produced and consumed by nodes.
- The runtime runs 8 static checks over the plan (acyclic, well-typed, projection-consistent, bounded, in-budget, …). A bad plan is rejected before anything executes.
- The executor runs the DAG in topological order. State flows between skills
through a full-fidelity handle table. The model is re-engaged only at
extract/summarisenodes - never to pass a value from one skill to the next. - The planner only ever sees the projected view of the handle table: each
handle at its declared level (
OPAQUE/SUMMARY/TRANSPARENT).
You can construct and execute a plan against a Catalog of skills with no model
in the loop - useful for tests and for understanding the runtime:
import { Catalog, Executor, Plan, Handle, Projection, SkillNode } from "oyadotai";
const cat = new Catalog();
cat.register({
name: "double", version: 1,
inputSig: { x: "int" }, outputSig: { y: "int" },
pure: true,
})(({ x }) => (x as number) * 2);
const plan = new Plan({
catalogSnapshot: cat.snapshot(),
handles: [
new Handle({ name: "x", type: "int", projection: Projection.OPAQUE, origin: "seed" }),
new Handle({ name: "y", type: "int", projection: Projection.OPAQUE, origin: "a" }),
],
nodes: [new SkillNode({ id: "a", skill: "double@1", inputs: ["x"], outputs: ["y"] })],
exits: ["y"],
});
const res = await new Executor(cat).run(plan, { x: 21 });
console.log(res.exits); // { y: 42 }Provide an LLMClient (the provider packages land soon; for now any object with a
complete({ system, user }) method works) and the Planner handles emit →
check → execute, with automatic re-emit on a bad plan and replan on a runtime
failure:
import { Planner } from "oyadotai";
const planner = new Planner(catalog, llmClient);
const result = await planner.run("Follow up with the lead at https://example.io/leads/abc123");
if (result.ok) {
console.log(result.execution!.exits);
// The URL never entered the planner's context.
}See the runnable example for the full SDR follow-up plan, and Projection Types for the discipline that makes it safe.