Hono Edge API skill for Codex
One typed Hono app for Workers, Bun, Deno and Node. Codex builds Hono services with typed Bindings and Variables, keeps runtime-specific code in the entry file, and validates every input with Zod through c.req.valid. Errors flow through HTTPException and onError into one JSON shape, routes are chained so the hc RPC client stays typed, and tests call app.request with fake bindings. Production deploys with wrangler wait for your approval.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- hono-edge-api Installs to ~/.agents/skills/hono-edge-api/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install hono-edge-api The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project hono-edge-api Installs to .agents/skills/hono-edge-api/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/hono-edge-api.zipunzip -o hono-edge-api.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) hono-edge-api PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $hono-edge-api, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds Hono APIs that run on Cloudflare Workers, Bun, Deno and Node - typed Bindings and Variables, route grouping with app.route, built-in middleware (cors, secure-headers, jwt, body-limit), createMiddleware, Zod validation with c.req.valid, HTTPException and onError, RPC clients via hc, and app.request tests. Use when starting a Hono project, porting an Express API to the edge, adding auth or validation middleware, sharing types with a frontend through Hono RPC, or debugging runtime-specific env and context errors.
Use it when
- Starting an API on Cloudflare Workers, Bun, Deno or Node with Hono
- Porting an Express or itty-router API to Hono
- Sharing request and response types with a frontend through Hono RPC
- Debugging env, bindings or executionCtx errors that differ between runtimes
Use something else when
- The question is Workers platform config, limits or bindings rather than routing: use cloudflare-workers
- The service is a long-running Node API with heavy plugin needs: use fastify-api
How to check the result
tsc and vitest pass, app.request tests cover 401, 422 and 404 JSON responses with fake bindings, and wrangler dev or the Node entry serves the health route locally.
The mistake it prevents
Reading configuration from process.env in Workers handlers instead of c.env, so bindings and secrets work under local Node runs but are missing on the Worker.
Done well means: the app is typed end to end (bindings, context variables, validated
input, RPC client), runtime specifics are confined to the entry file, every input is
validated before use, errors map to one JSON shape, and tests call app.request() with
no server or network.
Inspect first
- Read
AGENTS.md, thenpackage.json(honomajor 4.x, validator packages) and the runtime config:wrangler.toml/wrangler.jsonc(Workers),bunfig.toml,deno.json, or a Node entry using@hono/node-server. - Find the app root and sub-apps:
rg -n "new Hono|app\.route\(|createMiddleware" src. - Note which runtime APIs code touches (
c.env,c.executionCtx,process.env,Bun.*,Deno.*). Those calls decide portability.
Entry points by runtime
| Runtime | Entry | Env access |
|---|---|---|
| Cloudflare Workers | export default app | c.env.MY_BINDING (typed via Bindings) |
| Bun | export default app or export default { port: 3000, fetch: app.fetch } | process.env / Bun.env |
| Deno | Deno.serve(app.fetch) | Deno.env.get() (needs --allow-env) |
| Node | serve({ fetch: app.fetch, port: 3000 }) from @hono/node-server | process.env |
For code that must run everywhere, read env through env(c) from hono/adapter.
c.executionCtx exists only on Workers (and similar); accessing it on Node throws.
App structure
// src/app.ts
import { Hono } from "hono";
import { secureHeaders } from "hono/secure-headers";
import { cors } from "hono/cors";
import { requestId } from "hono/request-id";
import { HTTPException } from "hono/http-exception";
import { orders } from "./routes/orders";
export type Env = {
Bindings: { DB: D1Database; JWT_SECRET: string; ALLOWED_ORIGIN: string };
Variables: { userId: string };
};
const app = new Hono<Env>()
.use("*", requestId())
.use("*", secureHeaders())
.use("/api/*", (c, next) => cors({ origin: c.env.ALLOWED_ORIGIN, credentials: true })(c, next))
.get("/healthz", (c) => c.json({ ok: true }))
.route("/api/orders", orders);
app.onError((err, c) => {
if (err instanceof HTTPException) {
return c.json({ error: { code: err.status, message: err.message } }, err.status);
}
console.error(err);
return c.json({ error: { code: 500, message: "Internal server error" } }, 500);
});
app.notFound((c) => c.json({ error: { code: 404, message: "Not found" } }, 404));
export default app;
export type AppType = typeof app;
Chain route definitions (new Hono().get(...).post(...)) when you want RPC types:
the type of the chained expression carries the routes; routes added in separate
statements are not reflected in typeof app.
Middleware
// src/middleware/auth.ts
import { createMiddleware } from "hono/factory";
import { verify } from "hono/jwt";
import { HTTPException } from "hono/http-exception";
import type { Env } from "../app";
export const requireUser = createMiddleware<Env>(async (c, next) => {
const token = c.req.header("Authorization")?.replace(/^Bearer /, "");
if (!token) throw new HTTPException(401, { message: "Missing token" });
const payload = await verify(token, c.env.JWT_SECRET, "HS256").catch(() => null);
if (!payload?.sub) throw new HTTPException(401, { message: "Invalid token" });
c.set("userId", String(payload.sub));
await next();
});
- Always
await next(); a middleware that forgets to await breaks response ordering. - Built-ins worth knowing:
hono/cors,hono/secure-headers,hono/jwt,hono/bearer-auth,hono/body-limit,hono/etag,hono/timing,hono/logger,hono/request-id,hono/csrf. - Pass the JWT algorithm explicitly to
verifyrather than relying on defaults. - Middleware registered with
app.use("/api/*", ...)must come before the routes it guards; registration order is execution order.
Validation
// src/routes/orders.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { requireUser } from "../middleware/auth";
import type { Env } from "../app";
const CreateOrder = z.object({ sku: z.string().min(1), qty: z.number().int().positive() });
export const orders = new Hono<Env>()
.use("*", requireUser)
.post("/", zValidator("json", CreateOrder, (r, c) => {
if (!r.success) return c.json({ error: { code: 422, issues: r.error.issues } }, 422);
}), async (c) => {
const input = c.req.valid("json");
const id = crypto.randomUUID();
await c.env.DB.prepare("INSERT INTO orders (id, user_id, sku, qty) VALUES (?, ?, ?, ?)")
.bind(id, c.get("userId"), input.sku, input.qty).run();
return c.json({ id, ...input }, 201);
})
.get("/:id", zValidator("param", z.object({ id: z.string().uuid() })), async (c) => {
const { id } = c.req.valid("param");
const row = await c.env.DB.prepare("SELECT * FROM orders WHERE id = ? AND user_id = ?")
.bind(id, c.get("userId")).first();
if (!row) throw new HTTPException(404, { message: "Order not found" });
return c.json(row);
});
Targets: json, form, query, param, header, cookie. The json validator only
parses when the request has a JSON Content-Type; a missing header fails validation
rather than parsing. For Valibot or ArkType, use @hono/standard-validator.
RPC client
import { hc } from "hono/client";
import type { AppType } from "../server/src/app";
const client = hc<AppType>("https://api.example.com");
const res = await client.api.orders.$post({ json: { sku: "A1", qty: 2 } });
if (res.ok) { const order = await res.json(); }
Large apps make RPC type-checking slow; export per-route-group types and create clients per group if the editor lags.
Workers specifics
- Background work after the response:
c.executionCtx.waitUntil(promise). - Bindings (D1, KV, R2, Queues, Durable Objects) come from
wranglerconfig; regenerate types withnpx wrangler typesafter changing bindings. wrangler devruns locally on workerd.wrangler deploypublishes to production: ask the user before running it, and never deploy from an unreviewed branch.- No Node built-ins unless
nodejs_compatis enabled in the compatibility flags. - For D1, KV and R2 design questions, hand off to
$cloudflare-d1or$cloudflare-kv-queues.
Testing
import { describe, it, expect } from "vitest";
import app from "../src/app";
const env = { DB: fakeD1(), JWT_SECRET: "test-secret", ALLOWED_ORIGIN: "http://localhost" };
describe("orders", () => {
it("rejects missing token", async () => {
const res = await app.request("/api/orders", { method: "POST" }, env);
expect(res.status).toBe(401);
});
});
app.request(path, init, env) takes bindings as the third argument. For tests against
real Workers bindings, use @cloudflare/vitest-pool-workers. testClient from
hono/testing gives typed calls matching the RPC client.
Porting from Express
| Express | Hono |
|---|---|
req.params.id | c.req.param("id") or validated c.req.valid("param") |
req.query | c.req.query("k"), c.req.queries("k") |
req.body (after express.json) | await c.req.json() or validated json target |
res.status(201).json(x) | return c.json(x, 201) |
res.locals.user | c.set("user", u) / c.get("user") with typed Variables |
next(err) | throw new HTTPException(...) |
Every handler must return a Response. Forgetting return yields a 404 or an empty
response.
Verification
npx tsc --noEmit
npx vitest run
npx wrangler dev # Workers: then curl http://localhost:8787/healthz
Confirm the health route, a validation failure (422 JSON), an unauthenticated call (401
JSON) and an unknown path (404 JSON). Run wrangler deploy --dry-run to check the bundle
builds without publishing.
Gotchas
- Do not read config from
process.envin Workers code: it is populated only withnodejs_compaton recent compatibility dates, holds strings only, and never exposes D1, KV or R2 bindings. Usec.env(orenv(c)for portable code). - Module-level state in Workers persists per isolate, not per request and not globally; never use it for per-user data or counters.
- Webhook signature checks need the exact raw bytes: read
await c.req.text()(orarrayBuffer()) and verify before parsing, instead of re-serializing parsed JSON. - CORS middleware must run before auth so preflight
OPTIONSrequests are not rejected.
Output template
## Hono change: <summary>
- Runtime(s): <Workers | Bun | Deno | Node>
- Routes: <method path, validators, status codes>
- Middleware: <order and scope>
- Bindings/env added: <name, type, where configured>
- Tests: <app.request cases>
- Deploy: not run | run after user approval What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Hono Edge API"
short_description: "One typed Hono app for Workers, Bun, Deno and Node"
default_prompt: "$hono-edge-api Build a Hono API on Cloudflare Workers with D1, JWT auth and validation" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.