Agents & Tools (AI-Native Layer)
This is the layer that separates Patties from HonoX. Agents and tools are first-class filesystem entries — not configuration, not plugins. The framework wire…
Purpose
This is the layer that separates Patties from HonoX. Agents and tools are first-class filesystem entries — not configuration, not plugins. The framework wires the Anthropic SDK, exposes streamText and streamObject, and propagates request context to every agent call.
User contracts
Agent
// app/agents/booking.ts
import { defineAgent } from "patties/ai"
export default defineAgent({
name: "booking",
model: "claude-sonnet-4-6",
tools: ["search", "availability"], // tool names from app/tools/
systemPrompt: "...",
triggers: ["POST /api/booking/chat"] // optional auto-wiring
})
Tool
// app/tools/search.ts
import { defineTool } from "patties/ai"
import { z } from "zod"
export default defineTool({
name: "search",
description: "Search hotel inventory by city and dates.",
input: z.object({ city: z.string(), from: z.string(), to: z.string() }),
async handler(input, ctx) {
return { results: [] }
}
})
Framework exports
streamText(opts)— wraps Anthropic Messages streaming with sensible Patties defaults (prompt caching enabled, retry policy).streamObject(opts)— structured output streaming with a Zod schema.getAgent(name)— programmatic invocation in any route handler or background script.getTool(name)— same, for tools.createAiContext(opts)— build anAiContextfor callers outside a request (CLI scripts, cron jobs, queue handlers, scheduled tasks, tests).
AI context
Every agent and tool execution receives an AiContext:
interface AiContext {
requestId: string
user?: unknown // populated by an auth plugin if present
anthropic: Anthropic // per-context client, prompt-caching keys scoped here
signal?: AbortSignal
// free-form bag plugins may attach to:
vars: Record<string, unknown>
}
Inside a request
A framework middleware (registered by createRouter when any agent or tool exists) populates ctx.aiContext once per request. Route handlers do not need to pass it explicitly:
// app/routes/api/booking/chat.ts
export async function POST(req: Request, ctx: PattiesContext) {
const result = await getAgent("booking").run({ message: "..." }, ctx.aiContext!)
return new Response(result.stream, { headers: { "Content-Type": "text/event-stream" } })
}
Outside a request (programmatic)
getAgent(name).run(input, ctx) requires an explicit AiContext. Construct one with createAiContext:
import { createAiContext, getAgent } from "patties/ai"
const ctx = createAiContext({ requestId: crypto.randomUUID() })
await getAgent("booking").run({ message: "from a cron" }, ctx)
This keeps context flow visible and testable — no module-level globals, no AsyncLocalStorage traps. Tool handlers always receive the same ctx as their second argument.
Scheduled jobs (app/jobs/)
Recurring work has a first-class home parallel to app/agents/ and app/tools/:
// app/jobs/refresh-inventory.ts
import { defineJob } from "patties/ai"
export default defineJob({
name: "refresh-inventory",
schedule: "*/15 * * * *", // standard cron expression
tz: "Asia/Makassar", // explicit TZ required — no implicit process TZ
async handler(ctx) {
// ctx is AiContext, built per fire via createAiContext()
},
})
Behavior:
- On boot, the framework scans
app/jobs/**/*.tsviaBun.Globand registers each handler withBun.cron(schedule, handler)on thebuntarget. - Each fire constructs a fresh
AiContextviacreateAiContext()— no request to thread through, noc.var. - On the
edgetarget, the framework does not register cron handlers itself. The job inventory is exposed to deploy plugins via theonJobsCollecthook (09-plugins); each deploy plugin emits vendor-native cron triggers (wrangler.toml [triggers],vercel.json crons, etc.). - Multi-instance fires: Phase 2 ships without a singleton mechanism — multi-replica deploys fire each job N times. The
singleton: true + Redismechanism is a follow-up RFC once@patties/cachelands. Document this in dev logs at boot ifserver.reusePortis true.
See [[rfc-bun-cron]].
Triggers and route conflicts
triggers: ["POST /api/booking/chat"] is opt-in auto-wiring. When present, the framework registers a default handler at that method/path that streams the agent's response.
If a filesystem route file (e.g. app/routes/api/booking/chat.ts exporting POST) claims the same method+path:
- The filesystem route always wins. The framework does not register the trigger handler.
- A warning is logged at boot naming both the agent and the conflicting route file. The warning lists the path once per conflicting method.
- This is intentional: hand-written code is the source of truth; declarative triggers are convenience scaffolding the user can override at any time.
Name enforcement
Agent and tool name fields must match their filename basename (kebab-case, see 13-conventions):
app/agents/booking.tsmust exportdefineAgent({ name: "booking", ... }).- A mismatch (
name: "bookings"inbooking.ts) throws at boot with both names cited. - Boot collects all mismatches before throwing, so the developer sees every problem in one error.
- Two agents (or two tools) declaring the same
nameis also a boot error — both file paths are named in the message.
Anthropic SDK wiring
- The framework ships
@anthropic-ai/sdkas a peer dependency. - API key is read from
ANTHROPIC_API_KEYenv; failure to find one when an agent runs throws a typed error. - Prompt caching is on by default for system prompts and tool definitions.
Non-goals
- Multi-provider abstractions (Anthropic only — by design).
- A queue/worker layer. If you need queues, use whatever your host provides (Cloudflare Queues, AWS SQS, Upstash QStash, etc.) via a capability plugin — the framework stays neutral.
Acceptance criteria
- An agent with one tool can be invoked from an API route and streams a response.
- Tool input validation failures return a
400with the Zod issue list. - Removing
ANTHROPIC_API_KEYand invoking an agent throwsMissingAnthropicKeywith remediation text.