Every framework - ReAct, LangGraph, Mastra, the Vercel AI SDK - loops the model on each step: it reads a tool's output, re-types it as the next tool's arguments, and pays for the round trip. Oya compiles a typed plan once and executes the DAG. Values flow by reference, never back through the model.
Same code. Same tools. 10× fewer tokens · 3.5× faster · deterministic · injection-safe by construction.
· TypeScript · Bun · MIT · Drop-in for Mastra
Quickstart · The numbers · Why · Migrate in 2 lines · Studio · Docs · White paper
The open-source core behind oya.ai - the hosted platform for plan-don't-react agents.
make install && make demoimport { Agent, createTool } from "oyadotai";
import { anthropic } from "oyadotai/anthropic";
import { z } from "zod";
const getWeather = createTool({
id: "get_weather",
description: "Look up the weather for a city",
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => fetchWeather(city),
});
const agent = new Agent({
model: anthropic("claude-haiku-4-5-20251001"),
tools: { get_weather: getWeather },
});
const { text } = await agent.generate("How's the weather in NYC?");That's the whole API - the same shape as Mastra. Types are inferred from your
zod schema, and every value is OPAQUE to the model by default. You did less work
and it's injection-safe.
A token loop re-sends every tool result back through the model on the following
step, spending tokens, latency, and determinism to move state it never needed to
read. oya compiles the plan once and wires each value by reference. The
reconcile task - get a transaction → fetch its record → normalize → validate →
post - measures the difference on the real Anthropic API (claude-haiku-4-5,
identical tools, 8 trials):
| Vercel AI SDK | Mastra | oya | |
|---|---|---|---|
| total tokens | 5,754 | 24,387 | 2,536 |
| model round-trips | 6 | 6 | 2 |
| latency | 17.6s | 15.3s | 4.7s |
| execution order | model-chosen | model-chosen | one fixed DAG |
| state fidelity | unguaranteed | unguaranteed | guaranteed |
oya delivers the same result with 2.3× fewer tokens than the leanest loop, nearly
10× fewer than Mastra, in a third of the wall-clock time - and it does so
identically on every run. The record's bulky payload re-enters a loop's context
at every step; under oya it remains an OPAQUE handle the model never sees.
State fidelity is a guarantee, not an average. Every value flows by reference
as an OPAQUE handle that the model never re-reads or re-emits, so a URL, id, or
amount is delivered to the next tool byte-for-byte and the execution order is the
statically-checked DAG - outcomes a token loop can only approximate, one sampled
run at a time. Reproduce it yourself: bun run bench.
Don't take the table on faith - run it. The benchmark hits the real Anthropic
API with identical tasks and identical tool implementations for all three
frameworks (benchmarks/tasks.ts); the only thing that
varies is how much state flows through the model.
# 1. clone and install (needs Bun ≥ 1.1 - https://bun.sh)
git clone https://github.com/OyaAIProd/oya && cd oya
bun install
# 2. build the library - the benchmark imports the built `oyadotai` from dist/
bun run build
# 3. give it your Anthropic key (env var, or drop it in a .env at the repo root)
export ANTHROPIC_API_KEY=sk-ant-...
# 4. run the comparison
bun run bench # default: reconcile, claude-haiku-4-5, 3 trials
bun run bench claude-sonnet-5 # any model id as the first arg
bun run bench --task research # the heavy multi-doc caseArgs go in any order: a model id (defaults to claude-haiku-4-5-20251001), a
trial count (integer, defaults to 3), and --task reconcile / --task payments
/ --task research / --task weather (defaults to reconcile). Prefer
make bench - it runs the build for you and auto-loads a .env at the repo root,
so steps 2–4 collapse into one command.
The default reconcile task threads a critical token through a multi-hop pipeline
whose fetched record is a bulky payload carrying a look-alike distractor. It
measures token waste (that payload re-enters a loop's context at every step,
while oya keeps it OPAQUE), state fidelity (a provenance ledger confirms the
token reaches the final tool byte-for-byte), and ordering (a statically-checked
DAG). oya returns 0 corruption and one fixed order on every run, by construction.
--task research extends the comparison to a heavy multi-document workload.
Each framework runs the task N times against the same model, and prints tokens,
latency, and correctness side by side. The methodology is in
benchmarks/README.md.
Every other agent framework is a token loop (ReAct, LangGraph, AutoGen, Mastra, the Vercel AI SDK): the model picks a tool, sees the raw result, picks the next. Every URL, ID, and document flows back through the model. Three bugs follow - every time:
fetched: https://example.io/q3-report.pdf
downloaded: https://example.com/q3-report.pdf ← the model "fixed" the URL
expected: fetch → validate → download
observed: fetch → download → validate (skipped) ← the model reordered the steps
$432 in tokens ← re-reading every result through the model
$51 in tokens ← reading only what it needs to decide
Same root cause: the model read state it never needed to. oya makes that impossible. The model emits a typed dataflow plan; the runtime executes the DAG; each value is shown to the model only at the level the plan declares:
| level | the model sees | for |
|---|---|---|
OPAQUE (default) |
type + provenance - never the bytes | URLs, IDs, docs, payloads, secrets |
SUMMARY |
a bounded projection ({count}) |
facts to branch on |
TRANSPARENT |
the full value | the user's message, the final answer |
An attacker can stuff a payload into any fetched page. The model never reads that
handle, so indirect prompt injection through tool output has nowhere to land. You
annotate none of this - it's OPAQUE by default.
If your app already uses @mastra/core, oya is close to a drop-in. createTool
and Agent mirror the shapes you already write, so for most apps the entire
migration is the import lines.
Step 1 - swap the imports:
- import { Agent } from "@mastra/core/agent";
- import { createTool } from "@mastra/core/tools";
- import { anthropic } from "@ai-sdk/anthropic";
+ import { Agent, createTool } from "oyadotai";
+ import { anthropic } from "oyadotai/anthropic";Step 2 - there is no step 2. Your tool and agent definitions compile unchanged.
| Mastra | oya | Notes |
|---|---|---|
createTool({ id, description, inputSchema, execute }) |
same | Types are inferred from the zod inputSchema; execute's argument is typed for you - no casts. |
new Agent({ name, instructions, model, tools }) |
same | tools is the same name → tool map. |
await agent.generate(prompt) → { text } |
same | text is the headline field, and token usage is reported for parity. |
agent.stream(prompt) |
same call | Returns structured events (fullStream / textStream) instead of a raw token soup. |
anthropic(…) · openai(…) · google(…) |
oyadotai/anthropic · …/openai · …/google |
Same call shape, different import path. |
So the code you already have keeps working as-is:
import { Agent, createTool } from "oyadotai";
import { anthropic } from "oyadotai/anthropic";
import { z } from "zod";
const getWeather = createTool({
id: "get_weather",
description: "Look up the weather for a city",
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => fetchWeather(city),
});
const agent = new Agent({
name: "WeatherBot",
instructions: "Answer weather questions.",
model: anthropic("claude-haiku-4-5-20251001"),
tools: { get_weather: getWeather },
});
const { text } = await agent.generate("How's the weather in NYC?");Same surface, different engine. Mastra runs a token loop: the model calls a
tool, reads the raw result, and decides the next call - re-reading every value back
through the model. oya has the model emit one typed plan and executes that DAG
directly. Values flow between tools by reference and are disclosed to the model
only at their declared projection level (OPAQUE by default).
You didn't touch your code, but the migrated app now gets, for free:
- ~10× fewer tokens and ~3× faster on multi-hop tasks (the numbers above) - intermediate state is piped tool-to-tool, never re-billed through the model.
- Deterministic execution - one statically-checked order on every run, instead of a model-chosen sequence that can reorder or skip steps.
- Injection-safe by construction - tool output the model never reads can't smuggle an injected instruction into its context.
- Projection defaults to
OPAQUE. Tool outputs are hidden from the model unless you mark themSUMMARYorTRANSPARENT. That's the safety win - but if a tool relied on the model reading its raw output to decide the next step, declare that output's projection. See Projection Types. - Providers import from
oyadotai/*(oyadotai/anthropic,oyadotai/openai,oyadotai/google) rather than@ai-sdk/*. - oya is pre-1.0 and mirrors the core of the Mastra surface, not every helper. If something you depend on is missing, open an issue - Mastra parity gaps are high-priority.
Chat with your agents and watch each plan execute live - the DAG (React Flow, nodes
colored by kind and lit as they run), the trace, and every value at its projection
level (OPAQUE shows nothing, TRANSPARENT shows the value). Studio opens at
http://localhost:4000.
In this repo - two ways, both zero-setup and both the same UI (a sample
oya.config.ts is included):
make dev # the playground (builds libs, then runs it)
bun run build && bunx oyadotai dev # the CLI, serving oya.config.ts - same Studio, shipped in the packageIn your own project - add an oya.config.ts that exports your agents, then bunx oyadotai dev:
// oya.config.ts
import { Agent, createTool } from "oyadotai";
import { anthropic } from "oyadotai/anthropic";
export default {
agents: {
support: new Agent({ model: anthropic("claude-haiku-4-5-20251001"), tools: { /* ...createTool(...) */ } }),
},
};bunx oyadotai dev # → oya Studio at http://localhost:4000oya is a library, not a platform. Run an agent in a script, a Next.js route, a
Bun server, a worker, the edge - await agent.generate(prompt). Stream it with
agent.stream(prompt) (structured events, not a token soup) and render it with
oya/react's usePlan / useChat, or serve SSE with oyadotai-server.
Built on this: oya.ai
This repo is the open-source core that oya.ai runs on - the same plan-once runtime, projection types, and Studio, now hosted. If you want the managed platform (deploy agents, schedules, skills, and the Studio without running anything yourself) instead of wiring it up on your own infrastructure, start at oya.ai. Everything you build against this library is the same engine that powers it.
Full docs live in docs/ (served locally with make docs):
Guide
- Getting Started - install and write your first agent
- Creating an Agent - tools, instructions, and the plan-once model
- Model Providers - Anthropic, OpenAI, and Google, and swapping between them
- Configuring the Sandbox - where and how each tool's
executeruns - Studio - chat with your agents and watch the plan execute live
Concepts
- Projection Types - the
OPAQUE/SUMMARY/TRANSPARENTlattice, and why it closes prompt injection at the root - The Plan IR - the typed dataflow graph the planner emits and the runtime executes
bun add oyadotai zod| package | what |
|---|---|
oyadotai |
the runtime + Agent + createTool; oyadotai/anthropic · oyadotai/openai · oyadotai/google providers; oyadotai/react hooks; the oya dev studio |
oyadotai-server |
toSSEResponse / toTextResponse for any Fetch server |
@oya/playground |
the Next.js studio (make dev) |
@oya/benchmarks |
the live comparison above |
Everything runs through the Makefile - no need to remember package
paths. Run make help to list every target.
make install # install all workspace dependencies
make demo # ▶ the paced terminal demo - no API key, ~5s (the GIF above)
make dev # oya Studio (the playground) at http://localhost:4000
make example # run the weather example end-to-end (no network)
make bench # live benchmark vs Vercel AI SDK + Mastra (needs ANTHROPIC_API_KEY)
make test # bun:test - checked against the Python reference runtime
make check # typecheck + test, every package (exactly what CI runs)New here? make install && make demo shows the whole idea in five seconds.
oya is a community project built in the open, and the same core that powers oya.ai. Contributions of every size are welcome - bug reports, docs, examples, providers, and features.
- Contributing guide - setup, the correctness oracle, and the PR workflow
- Good first issues · Help wanted
- Discussions - questions, ideas, and show-and-tell
- Code of Conduct · Governance · Security policy
Found a security issue? Please report it privately - see SECURITY.md. Anything else: mk@oya.ai.
oya is the TypeScript implementation of Plan, Don't React: Projection Types for LLM Agent Runtimes. Read the white paper:
MIT © Oya Labs, Inc.
