One Endpoint
The whole transport layer fits on a screen. The envelope is validated before
anything else happens; a liveness fast-path answers io/ping without touching
the agent; everything else is dispatched:
// server/src/http.ts
export const http = new Hono()
http.onError(errorHandler)
function ensureMessage(source: unknown) {
try {
return zMessage.parse(source) as Message
} catch (cause) {
throw new InvalidError("invalid message format", { cause })
}
}
// every command/query enters through this single endpoint
http.post("/io", async ({ req, json }) => {
const message = ensureMessage(await req.json())
// liveness check
if (message.type === "command" && message.to === "io/ping") {
return json(message.data)
}
const data = await agent.process(message)
const reply: Message = {
type: "reply",
from: agent.id,
to: message.from,
data,
}
return json(reply)
})
Note the symmetry: the reply is itself a Message, addressed back to the
sender. The transport doesn’t know what operations exist — routing happens on
message.to inside the agent, not in HTTP.
The Envelope Schema
The wire contract lives in the shared package, so the client and server can never drift apart:
// shared/src/domain.ts
export const zMessage = z.object({
type: zMessageType, // "command" | "query" | "reply" | "event"
from: zMessageFrom, // x:… network id + token, 37–512 chars
to: String64, // operation name
data: AnyRecord,
meta: z.optional(AnyRecord),
})
Error Handling
All failures leave through one Hono onError handler. It maps the typed error
taxonomy (unauthenticated, unauthorized, invalid, not_found, failed,
unknown, …) to a uniform reply shape — and strips stack traces before
anything reaches the client:
// server/src/http.error.ts (excerpt)
export const errorHandler: ErrorHandler = (error, { json }) => {
console.error("IO Error:", error)
// validation errors
if (isValidationError(error)) {
const invalid = new InvalidError(error.message, { errors: error.issues })
return json(toReplyError(invalid), 400)
}
if (error instanceof InvalidError) {
return json(toReplyError(error), 400)
}
// unknown or uncaught errors
const unknown = new UnknownError(error.message, { cause: error })
return json(toReplyError(unknown), 500)
}
Clients always receive the same shape — { type, message, errors } — which
the UI can surface directly (a rules-engine rejection like “Field “isbn” is
required“ travels all the way to the form).
Why a Single Endpoint?
- One checkpoint for authentication, authorization, and validation — no route can forget its middleware.
- Uniform requests make cross-cutting tooling trivial: logging, rate limiting, tracing, and replay all hook one seam.
- The envelope generalizes:
from/toaddress agents and operations, so the same transport supports future agent-to-agent relay — something a URL-per-resource API can’t express.
The trade-off — losing per-route HTTP semantics (verbs, status-code variety, URL caching) — is a conscious one: this API is a command bus, not a hypermedia surface.