Honi

Edge-first AI agents for Cloudflare Workers.
The Mastra for developers who deploy on Cloudflare.
Honi is a lightweight framework for building agentic AI applications on Cloudflare Workers. It combines Hono for routing, Durable Objects for persistent memory, and the Vercel AI SDK for multi-provider LLM support — all in a single createAgent() call.
Quick Start
Install
bun add honidev
Create an Agent
import { createAgent, tool, z } from 'honidev';
const searchCRM = tool({
name: 'search_crm',
description: 'Search HubSpot for deal info',
input: z.object({ query: z.string() }),
handler: async ({ query }) => {
return { results: [] };
},
});
const salesCoach = createAgent({
name: 'sales-coach',
model: 'claude-sonnet-4-5',
memory: { enabled: true },
tools: [searchCRM],
system: 'You are a real-time sales coach.',
binding: 'SALES_COACH_DO',
});
export default { fetch: salesCoach.fetch };
export const SalesCoachDO = salesCoach.DurableObject;
Configure Wrangler
name = "sales-coach-agent"
main = "src/index.ts"
compatibility_date = "2024-12-01"
[[durable_objects.bindings]]
name = "SALES_COACH_DO"
class_name = "SalesCoachDO"
[[migrations]]
tag = "v1"
new_classes = ["SalesCoachDO"]
Deploy
wrangler deploy
Chat with Your Agent
curl -X POST https://your-worker.workers.dev/chat \
-H "Content-Type: application/json" \
-H "X-Thread-Id: thread-123" \
-d '{"message": "What deals are closing this week?"}'
Memory (Phase 2)
Honi supports four tiers of memory, all opt-in:
| Working | Durable Object storage | No | No |
| Episodic | D1 | Yes | Yes |
| Semantic | Vectorize + Workers AI | Yes | Yes (similarity search) |
| Graph | edgraph (DO) | Yes | Yes (entity/relationship traversal) |
Setup
Add bindings to your wrangler.toml:
[[d1_databases]]
binding = "DB"
database_name = "honi-memory"
database_id = "YOUR_DB_ID"
[[vectorize]]
binding = "VECTORIZE"
index_name = "honi-semantic"
dimensions = 768
metric = "cosine"
[ai]
binding = "AI"
[[services]]
binding = "EDGRAPH"
service = "edgraph"
Run the D1 migration:
wrangler d1 migrations apply honi-memory
Configure
const agent = createAgent({
name: 'my-agent',
model: 'claude-sonnet-4-5',
memory: {
enabled: true,
episodic: { enabled: true },
semantic: { enabled: true, topK: 3 },
graph: {
enabled: true,
graphId: 'my-knowledge-base',
binding: 'EDGRAPH',
apiKeyEnvVar: 'EDGRAPH_API_KEY',
contextDepth: 1,
},
},
system: 'You are a helpful assistant.',
});
All tiers are fully opt-in. Missing bindings log a warning and fall back gracefully.
How it works
- On each request: past messages load from D1 (episodic), the user message is embedded and searched against Vectorize (semantic), and any entity IDs found in semantic results are expanded via graph traversal. All context is prepended to the system prompt.
- After each response: the conversation turn is saved to D1 and both user + assistant messages are embedded and upserted to Vectorize for future retrieval.
Graph memory — writing entities from tools
Graph memory is most powerful when tools write entities as they discover them:
import { tool, GraphMemory } from 'honidev';
const myTool = tool({
name: 'lookup_customer',
description: 'Look up a customer by ID',
input: z.object({ customerId: z.string() }),
handler: async (input, ctx) => {
const customer = await db.getCustomer(input.customerId);
if (ctx?.graph && customer) {
await ctx.graph.upsertNode(customer.id, 'Customer', {
name: customer.name,
plan: customer.plan,
});
if (customer.accountManagerId) {
await ctx.graph.upsertEdge(
customer.id,
customer.accountManagerId,
'managed_by',
);
}
}
return customer;
},
});
ctx.graph is the live GraphMemory instance bound to the current agent. Entities written here are immediately available for future context retrieval.
Using GraphMemory standalone
GraphMemory can also be used outside of an agent — as a shared knowledge base across multiple services:
import { GraphMemory } from 'honidev';
const graph = new GraphMemory({
graphId: 'crm',
url: 'https://edgraph.myapp.workers.dev',
apiKey: process.env.EDGRAPH_API_KEY,
});
await graph.upsertNode('alice', 'Person', { role: 'CTO', company: 'ACME' });
await graph.upsertNode('acme', 'Company', { industry: 'SaaS' });
await graph.upsertEdge('alice', 'acme', 'works_at');
const context = await graph.toContext(['alice'], 2);
const path = await graph.shortestPath('alice', 'bob');
const neighbours = await graph.getNeighbours('alice', 'out', ['manages']);
Core Concepts
Agents
An agent is created with createAgent() and bundles:
- A Hono-powered HTTP API with
/chat, /history routes
- A Durable Object class for persistent, per-thread state
- An LLM connection with automatic tool-calling loops and streaming
Tools
Define tools with Zod schemas. Honi auto-generates the JSON schema for the LLM and validates inputs at runtime:
const weatherTool = tool({
name: 'get_weather',
description: 'Get current weather for a city',
input: z.object({
city: z.string().describe('City name'),
units: z.enum(['celsius', 'fahrenheit']).default('celsius'),
}),
handler: async ({ city, units }) => {
return { temp: 22, city, units };
},
});
Memory
When memory: { enabled: true }, conversation history is persisted in Durable Object storage. Each thread (identified by X-Thread-Id header) gets its own isolated memory.
POST /chat — send a message, get a streaming response
GET /history?threadId=xxx — retrieve conversation history
DELETE /history?threadId=xxx — clear a thread's history
Supported Models
Honi uses the Vercel AI SDK under the hood. Model routing is automatic based on the model ID prefix:
claude-* | Anthropic | ANTHROPIC_API_KEY | claude-sonnet-4-5 |
bedrock/* | AWS Bedrock (Anthropic models) | AWS_BEARER_TOKEN_BEDROCK + AWS_BEDROCK_REGION | bedrock/anthropic.claude-haiku-4-5 |
gpt-*, o1, o3-* | OpenAI | OPENAI_API_KEY | gpt-4o, o3-mini |
gemini-* | Google | GOOGLE_AI_API_KEY | gemini-2.5-flash-preview |
groq/* | Groq | GROQ_API_KEY | groq/llama-3.3-70b-versatile |
deepseek-* | DeepSeek | DEEPSEEK_API_KEY | deepseek-chat, deepseek-reasoner |
mistral-*, codestral-*, pixtral-* | Mistral | MISTRAL_API_KEY | mistral-large-latest |
grok-* | xAI | XAI_API_KEY | grok-3, grok-3-mini |
sonar*, perplexity/* | Perplexity | PERPLEXITY_API_KEY | sonar-pro, sonar-reasoning |
together/* | Together AI | TOGETHER_API_KEY | together/meta-llama/Llama-3.3-70B-Instruct-Turbo |
command-* | Cohere | COHERE_API_KEY | command-r-plus, command-a-03-2025 |
azure/* | Azure OpenAI | AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT | azure/gpt-4o |
@cf/* | Workers AI | AI binding in wrangler.toml | @cf/meta/llama-3.1-8b-instruct |
Workers AI (@cf/*) and AI Gateway support are built in — no extra packages, and no API keys needed (see below). Other non-core providers use optional peer dependencies — zero bundle cost unless installed. Set API keys as Cloudflare Worker secrets:
wrangler secret put ANTHROPIC_API_KEY
wrangler secret put GOOGLE_AI_API_KEY
Non-Anthropic/OpenAI/Google providers require their AI SDK package:
npm install @ai-sdk/groq
npm install @ai-sdk/deepseek
npm install @ai-sdk/mistral
npm install @ai-sdk/xai
npm install @ai-sdk/perplexity
npm install @ai-sdk/togetherai
npm install @ai-sdk/cohere
npm install @ai-sdk/azure
Workers AI — no API key needed
Use any Workers AI model by prefixing with @cf/. Inference runs on your Cloudflare account via the AI binding — no API key, no extra packages:
[ai]
binding = "AI"
const agent = createAgent({
name: 'my-agent',
model: '@cf/meta/llama-3.3-70b-instruct-fp8-fast',
system: 'You are a helpful assistant.',
});
AI Gateway — hosted models without BYOK
Route all LLM calls through Cloudflare AI Gateway for logging, rate limiting, and caching at the edge. When your gateway holds provider credentials — via BYOK stored keys or Unified Billing — your Worker needs no provider API keys at all:
const agent = createAgent({
name: 'my-agent',
model: 'claude-sonnet-4-5',
aiGateway: {
gatewayId: 'your-gateway-id',
},
system: 'You are a helpful assistant.',
});
Gateway authentication resolves in this order:
- AI binding (recommended): with
[ai] binding = "AI" in wrangler.toml, Honi calls the gateway through env.AI.gateway() — fully keyless from the Worker.
- Gateway token: without the binding, set
accountId and store an AI Gateway token as a secret (wrangler secret put CF_AIG_TOKEN). Honi sends it as cf-aig-authorization:
aiGateway: {
accountId: 'your-account-id',
gatewayId: 'your-gateway-id',
tokenEnvVar: 'CF_AIG_TOKEN',
},
Provider API keys set in the Worker still work and are passed through — the gateway's stored keys simply take precedence when configured. Gateway routing covers Anthropic, OpenAI, Google, Groq, DeepSeek, Mistral, xAI, Perplexity, and Azure OpenAI; Together AI and Cohere always connect directly. Workers AI models (@cf/*) log through the gateway natively via the binding.
The legacy observability.aiGateway config is still honored, but prefer top-level aiGateway.
AWS Bedrock — Claude on AWS infrastructure
Run Anthropic models on AWS-operated serving infrastructure with the bedrock/ prefix. Honi targets Bedrock's mantle endpoint, which speaks the Anthropic Messages API natively with plain bearer-token auth — no SigV4 signing and no AWS SDK in your Worker:
const agent = createAgent({
name: 'my-agent',
model: 'bedrock/anthropic.claude-haiku-4-5',
system: 'You are a helpful assistant.',
});
wrangler secret put AWS_BEARER_TOKEN_BEDROCK
wrangler secret put AWS_BEDROCK_REGION
Notes:
- The model id after
bedrock/ is passed to Bedrock verbatim and uses Bedrock's naming (anthropic.claude-haiku-4-5), not Anthropic's.
- Pick the region deliberately — it decides where prompts are processed. If you have data-residency requirements, choose a region (and account-level inference profile) that guarantees them.
- Bedrock is an independent failure domain from
api.anthropic.com running the same models, which makes a claude-* ⇄ bedrock/* pair a natural primary/failover setup.
bedrock/* cannot be combined with aiGateway — the gateway's Bedrock support uses its own endpoint scheme. Honi throws rather than silently going direct.
Gateway request options — caching, retries, privacy
aiGateway.options passes per-request gateway options through to Cloudflare (they become cf-aig-* headers), on both the binding and token auth paths:
aiGateway: {
gatewayId: 'your-gateway-id',
options: {
collectLog: false,
metadata: { app: 'my-app' },
cacheTtl: 3600,
retries: { maxAttempts: 3, backoff: 'exponential' },
requestTimeoutMs: 30_000,
},
},
Privacy note: the gateway stores full request and response bodies by default, and there is no gateway-level setting to turn that off. If your prompts carry user data, set collectLog: false — token counts, model, cost and latency are still logged; the payloads are not.
Streaming
All responses stream via the AI SDK data protocol, compatible with the useChat() hook from ai/react:
import { useChat } from 'ai/react';
const { messages, input, handleSubmit } = useChat({
api: 'https://your-worker.workers.dev/chat',
headers: { 'X-Thread-Id': 'thread-123' },
});
Comparison
| Runtime | Cloudflare Workers | Node.js | Node.js |
| Memory | Durable Objects | Postgres / Redis | Various |
| Streaming | SSE (AI SDK) | SSE | Callbacks |
| Deploy | wrangler deploy | Self-host | Self-host |
| Cold start | ~0 ms (edge) | Seconds | Seconds |
| Bundle size | Minimal | Heavy | Heavy |
| Tool system | Zod-native | Zod-native | Mixed |
| Framework | Hono | Express / Hono | Custom |
Examples
Ready-to-deploy example projects in the examples/ directory:
Each example includes a wrangler.toml, package.json, and README with setup instructions.
Workflows (Phase 3)
Honi wraps Cloudflare Workflows with a simple workflow() + step() API for durable, multi-step agent pipelines.
import { workflow, step } from 'honidev';
const IngestWorkflow = workflow({
steps: [
step({ name: 'fetch-data', retries: { limit: 3, backoff: 'exponential' } }, async (input, step) => {
const res = await fetch(input.url);
return res.json();
}),
step({ name: 'process', timeout: '60s' }, async (data, step) => {
return { processed: true, items: data.length };
}),
],
onComplete: async (result, env) => {
console.log('Pipeline complete:', result);
},
onError: async (error, env) => {
console.error('Pipeline failed:', error.message);
},
});
export { IngestWorkflow };
Add to wrangler.toml:
[[workflows]]
name = "ingest-workflow"
binding = "INGEST_WORKFLOW"
class_name = "IngestWorkflow"
CLI (Phase 4)
Honi includes a CLI for scaffolding and managing projects.
npm install -g honidev
honi new my-sales-coach
honi dev
honi deploy
honi new generates a ready-to-run project with src/index.ts, wrangler.toml, tsconfig.json, and package.json.
Observability (Phase 5)
Honi emits structured events for every agent request, tool call, memory operation, and workflow step.
Configuration
const agent = createAgent({
name: 'my-agent',
model: 'claude-sonnet-4-5',
observability: {
captureEvents: false,
logLevel: 'debug',
onEvent: (event) => {
console.log(event.type, event.durationMs);
},
},
});
Event Types
agent.request | Incoming chat request |
agent.phase | Model resolution, tool setup, prompt construction, and optional memory-context phases finish |
agent.stream.first_chunk | First provider output arrives; durationMs is provider time-to-first-output |
agent.step | One AI SDK model/tool-loop step finishes |
agent.response | Response stream complete — metadata carries model, usage, finishReason, providerMetadata |
agent.turn.complete | Response and all configured persistence have finished |
tool.call | Tool execution starts |
tool.result | Tool execution finishes |
memory.load | Memory loaded from storage |
memory.save | Memory persisted |
workflow.start | Workflow begins |
workflow.step | Workflow step executes |
workflow.complete | Workflow finishes |
workflow.error | Workflow errors |
Phase, memory, and tool events are content-free: they carry timings, bounded counts, model/tool
names, outcomes, and error classes, never prompts, model output, tool arguments, tool results, or
raw error messages.
Token usage & cost telemetry
agent.response includes token counts in metadata, so consumers can attribute LLM spend per call without proxying anything:
observability: {
onEvent: (event) => {
if (event.type === 'agent.response') {
const { model, usage, providerMetadata } = event.metadata ?? {};
recordSpend(model, usage, providerMetadata);
}
},
},
AI Gateway
Set top-level aiGateway to route LLM calls through Cloudflare AI Gateway for logging, rate limiting, and caching at the edge — see AI Gateway — hosted models without BYOK. The legacy observability.aiGateway shape is still honored.
API Reference
createAgent(config)
name | string | — | Agent name |
model | string | — | Model ID (claude-sonnet-4-5, gpt-4o) |
modelSettings | ModelSettings | — | AI SDK generation + provider controls |
system | string | — | System prompt |
memory | MemoryConfig | {} | Memory configuration (see Phase 2) |
tools | ToolDefinition[] | [] | Agent tools |
binding | string | "AGENT" | Durable Object binding name |
maxSteps | number | 10 | Max tool-calling loop iterations |
aiGateway | AiGatewayConfig | — | Route LLM calls through CF AI Gateway |
cache | boolean | CacheConfig | false | Anthropic prompt caching (see below) |
Returns { fetch, DurableObject }.
Model controls (AI SDK)
modelSettings passes the AI SDK's common generation controls to every step:
maxTokens, temperature, topP, topK, presence/frequency penalties, stop
sequences, seed, and retry count. Provider-specific capabilities go under the
AI SDK's providerOptions namespace and are passed through unchanged:
createAgent({
name: 'voice-agent',
model: 'claude-haiku-4-5',
modelSettings: {
temperature: 0,
maxTokens: 512,
providerOptions: {
anthropic: { thinking: { type: 'disabled' } },
},
},
});
Honi deliberately does not invent a cross-provider thinking abstraction:
providers expose different semantics, and the provider adapter remains the
authority. Unsupported options are handled according to that adapter/model's
normal AI SDK behavior.
Prompt caching (Anthropic)
A large system prompt re-prefilled on every turn is usually the dominant term in
an agent's time-to-first-token. cache: true places two Anthropic cache
breakpoints so that work happens once:
createAgent({
name: 'support',
model: 'claude-haiku-4-5',
system: BIG_SYSTEM_PROMPT,
tools,
memory: { enabled: true },
cache: true,
});
cache.system | tools + system prompt | Anthropic serialises tools ahead of system, so one marker covers both — and both are byte-stable across every turn of every thread. |
cache.history | the conversation prefix, re-anchored each turn | Each turn reads the cache the previous turn wrote, so a growing thread stays roughly O(1) to prefill instead of O(n). |
Both default to on when cache is set; opt out individually with
cache: { history: false }.
Caching is off by default because turning it on changes how the prompt is
assembled. The Anthropic provider reads cache control off a message, and a
top-level system string carries no providerOptions — so the system prompt
has to move into messages to be markable at all. With cache unset the prompt
is assembled exactly as before.
Worth knowing:
- Anthropic ignores cache control below a minimum cacheable prefix — 1024
tokens for Opus/Sonnet, 2048 for Haiku — silently, with no error. A
small-prompt agent will see no effect and no warning.
- The default TTL is 5 minutes, refreshed on every read. Turns seconds apart
keep it warm; a long gap costs one cold prefill.
- A cache write is 1.25x base input, a read 0.1x — so with a large stable prefix
this is a cost saving as well as a latency one.
- Anthropic allows 4 breakpoints; this uses 2.
- The
anthropic namespace is ignored by other providers, but the assembly
change applies to all of them. The AI SDK normalises both forms to the same
internal prompt, so it should be equivalent — the opt-in default is there so
nobody finds out the hard way.
- To confirm it is working, read the cache buckets off
providerMetadata on the
agent.response observability event: cacheReadInputTokens should climb and
inputTokens collapse from the second turn on.
tool(config)
name | string | Tool name (sent to LLM) |
description | string | What the tool does |
input | ZodType | Zod schema for tool input |
handler | (input) => Promise<unknown> | Tool implementation |
License
MIT
Bundle Size
Honi is designed to fit comfortably within Cloudflare Workers' limits:
| Honi library (dist/) | ~30 KB |
| Full demo app (with AI SDK) | 691 KB uncompressed |
| Gzip compressed | 123 KB |
| CF Workers limit | 1 MB compressed |
You're using ~12% of the limit with a full-featured agent including all AI SDK providers.
Optimization Tips
- Only import the providers you need (
@ai-sdk/anthropic OR @ai-sdk/openai, not both)
- Use Workers AI (
@cf/ models) to skip external provider SDKs entirely
- Tree-shaking works — unused features don't add to bundle size
MCP Server
Honi agents can expose their tools as MCP endpoints, allowing connection from Claude Desktop, Cursor, and other MCP-compatible clients.
Endpoints
/mcp | POST | JSON-RPC 2.0 MCP endpoint |
/mcp/tools | GET | List available tools (convenience) |
Authentication
For remote connections (Cursor, custom clients, any non-local use) you should lock down /mcp with a Bearer token. Set mcp.secretEnvVar in your agent config:
const agent = createAgent({
name: 'my-agent',
model: 'claude-sonnet-4-5',
tools: [searchDocs],
mcp: { secretEnvVar: 'MCP_SECRET' },
})
Then set the secret via Wrangler:
wrangler secret put MCP_SECRET
Clients send Authorization: Bearer <secret> on every request. If secretEnvVar is not set, /mcp is unauthenticated — fine for local Claude Desktop (stdio transport), but don't expose it publicly without this.
Example: Connect from Claude Desktop
Add to your Claude Desktop MCP config:
{
"mcpServers": {
"my-honi-agent": {
"url": "https://my-agent.workers.dev/mcp"
}
}
}
For authenticated remote connections:
{
"mcpServers": {
"my-honi-agent": {
"url": "https://my-agent.workers.dev/mcp",
"headers": {
"Authorization": "Bearer your-secret-here"
}
}
}
}
Multi-Agent Orchestration
Honi supports agent-to-agent communication for building complex agentic workflows.
import { routeToAgent, callAgentTool, listAgentTools } from 'honidev';
const response = await routeToAgent(env, { binding: 'OTHER_AGENT' }, 'Hello!');
const result = await callAgentTool(env, { binding: 'OTHER_AGENT' }, 'search', { query: 'test' });
const tools = await listAgentTools(env, { binding: 'OTHER_AGENT' });
wrangler.toml Setup
[[durable_objects.bindings]]
name = "MY_AGENT"
class_name = "MyAgentDO"
[[durable_objects.bindings]]
name = "OTHER_AGENT"
class_name = "OtherAgentDO"
Recursive Memory — RLM tier (Phase 6)
Recursive memory implements the Recursive Language Model pattern from Zhang, Kraska & Khattab (MIT CSAIL, 2025). Instead of one-shot RAG retrieval, the model iteratively queries a document store — deciding what to read at each step based on what it has already learned.
| Recursive | Durable Object storage | No (session-scoped) | Structured KB, support docs, product manuals |
How it differs from Semantic (RAG)
| Retrieval | Embedding similarity — a guess before reasoning | Model decides what to read during reasoning |
| Cross-references | Misses joins — top-k doesn't follow links | Iterative — reads lead to further reads |
| Structured data | Flattens tables/matrices into embeddings | Queries structure directly |
| Token cost | One large context per call | Many small calls — reads only what's needed |
| Best for | Unstructured text, past conversations | Product docs, error codes, KB articles, version matrices |
Configure
No extra bindings needed — recursive memory uses the agent's existing Durable Object storage.
const agent = createAgent({
name: 'support-agent',
model: 'claude-sonnet-4-5',
memory: {
recursive: {
enabled: true,
maxDepth: 10,
timeoutMs: 30_000,
chunkSize: 800,
},
},
});
For voice agents where latency is critical:
memory: {
recursive: { enabled: true, maxDepth: 5, timeoutMs: 5_000 },
}
Loading documents
Load KB documents from a tool, a Worker startup handler, or a DO alarm:
const loadKb = tool({
name: 'load_kb',
description: 'Load a KB article into the document store',
input: z.object({ id: z.string(), content: z.string(), title: z.string().optional() }),
handler: async ({ id, content, title }, ctx) => {
await ctx.recursive!.loadDocument(id, content, title);
return { ok: true };
},
});
Or directly on agent startup:
const agentId = env.AGENT.idFromName('support');
const agentStub = env.AGENT.get(agentId);
await agentStub.fetch(new Request('https://do/load-kb', {
method: 'POST',
body: JSON.stringify({ id: 'bridge-upgrade', content: kbArticle }),
}));
How the loop works
- User message arrives.
runLoop() fires before streamText — the model calls search(), read_chunks(), and get_index() iteratively via generateText with maxSteps.
- Each tool call executes against DO storage — sub-millisecond, no network hop.
- When the model returns text instead of a tool call, the loop ends.
- The research result is injected into the system prompt.
streamText produces the final streamed response to the client using the enriched context.
RecursiveMemory API
For direct use outside createAgent():
import { RecursiveMemory } from 'honidev';
const mem = new RecursiveMemory(doStorage, { enabled: true, chunkSize: 800 });
await mem.loadDocument('doc-id', content, 'Optional Title');
const hits = await mem.search('activation error arm mac');
const chunks = await mem.readChunks([0, 1, 2]);
const index = await mem.getIndex();
const result = await mem.runLoop(userMessage, model, systemPrompt);