Practical notes for an eventual migration of packages/opencode server routes from the current Hono handlers to Effect HttpApi, either as a full replacement or as a parallel surface.
Use Effect HttpApi where it gives us a better typed contract for:
This should be treated as a later-stage HTTP boundary migration, not a prerequisite for ongoing service, route-handler, or schema work.
HttpApi is definition-first.
HttpApi is the root APIHttpApiGroup groups related endpointsHttpApiEndpoint defines a single route and its request / response schemasThis is a better fit once route inputs and outputs are already moving toward Effect Schema-first models.
The current route-effectification work is already pushing handlers toward:
AppRuntime.runPromise(Effect.gen(...)) bodyThat work is a good prerequisite for HttpApi. Once the handler body is already a composed Effect, the remaining migration is mostly about replacing the Hono route declaration and validator layer.
Request params, query, payload, success payloads, and typed error payloads are declared in one place using Effect Schema.
Incoming data is decoded through Effect Schema instead of hand-maintained Zod validators per route.
HttpApi can derive OpenAPI from the API definition, which overlaps with the current describeRoute(...) and resolver(...) pattern.
Schema.TaggedErrorClass maps naturally to endpoint error contracts.
Best fit first:
Harder / later fit:
Many route boundaries still use Zod-first validators. That does not block all experimentation, but full HttpApi adoption is easier after the domain and boundary types are more consistently Schema-first with .zod compatibility only where needed.
Many current server/routes/instance/*.ts handlers still mix composed Effect code with smaller Promise- or ALS-backed seams. Migrating those to consistent Effect.gen(...) handlers is the low-risk step to do first.
The server currently includes SSE, websocket, and streaming-style endpoints. Those should not be the first HttpApi targets.
The current server composition, middleware, and docs flow are Hono-centered today. That suggests a parallel or incremental adoption plan is safer than a flag day rewrite.
server/routes/instance/*.tsIntroduce one small HttpApi group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
server/routes/instance/question.tsserver/routes/instance/provider.tsserver/routes/instance/permission.tsAvoid session.ts, SSE, websocket, and TUI-facing routes first.
Recommended first slice:
questionGET /questionPOST /question/:requestID/replyWhy question first:
Do not re-architect business logic during the HTTP migration. HttpApi handlers should call the same Effect services already used by the Hono handlers.
The HttpApi routes are bridged into the Hono server via HttpRouter.toWebHandler with a shared memoMap. This means:
AppRuntime (same Question.Service, etc.).all() catch-all intercepts matching paths before the Hono route handlersThe bridge is gated behind OPENCODE_EXPERIMENTAL_HTTPAPI (or OPENCODE_EXPERIMENTAL). When the flag is off (default), all requests go through the original Hono handlers unchanged.
// in instance/index.ts
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
const handler = ExperimentalHttpApiServer.webHandler().handler
app.all("/question", (c) => handler(c.req.raw)).all("/question/*", (c) => handler(c.req.raw))
}
The Hono route handlers are always registered (after the bridge) so hono-openapi generates the OpenAPI spec entries that feed SDK codegen. When the flag is on, these handlers are dead code — the .all() bridge matches first.
The webHandler provides Observability.layer via Layer.provideMerge. Since the memoMap is shared with AppRuntime, the tracing provider is deduplicated — no extra initialization cost.
This gives:
Effect.fn("QuestionHttpApi.list") etc. appear in traces alongside service-layer spansHttpMiddleware.logger emits structured Effect.log entries with http.method, http.url, http.status annotations, flowing to motel via OtlpLoggerAs each route group is ported to HttpApi:
.get(...) / .post(...) bridge entries to the flag block in server/routes/instance/index.tsGET /provider/auth), bridge only the specific pathLeave streaming-style endpoints on Hono until there is a clear reason to move them.
Every HttpApi slice should follow specs/effect/schema.md and the Schema -> Zod interop rule in specs/effect/migration.md.
Default rule:
.zod exists only as a compatibility surfacePractical implication for HttpApi migration:
@/util/effect-zodOrdering for a route-group migration:
schema.ts leaf types to Effect Schema firstInfo / Input / Output route DTOs to Effect SchemaSchema.TaggedErrorClass where needed.zodHttpApi contract from the canonical Effect schemas./packages/sdk/js/script/build.ts) and verify zero diff against devSDK shape rule:
packages/sdk/js/src/v2/gen/types.gen.ts, the migration introduced an unintended API surface change — fix it before mergingThe pattern choice determines whether a schema becomes a named export in the SDK or stays anonymous inline.
Schema.Class emits a named $ref in OpenAPI via its identifier → produces a named export type Foo = ... in types.gen.ts:
export class Info extends Schema.Class<Info>("FooConfig")({ ... }) {
static readonly zod = zod(this)
}
Schema.Struct stays anonymous and is inlined everywhere it is referenced:
export const Info = Schema.Struct({ ... }).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export type Info = Schema.Schema.Type<typeof Info>
When to use each:
.meta({ ref: ... }) (preserve the existing named SDK type byte-for-byte)Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (export type Foo = ... newly appears) even if it preserves the JSON shape.
Schemas that are not pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add .annotate({ identifier: "FooName" }) to get the same named-ref behavior:
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
Temporary exception:
The first HttpApi spike should be intentionally small and repeatable.
Chosen slice:
questionGET /question and POST /question/:requestID/replyNon-goals:
session routesBehavior rule:
404 behavior as a separate follow-up unless they are required to make the contract honestAdd POST /question/:requestID/reject only after the first two endpoints work cleanly.
Use the same sequence for each route group.
HttpApi contract separately from the handlers.OPENCODE_EXPERIMENTAL_HTTPAPI bridge.dev (see SDK shape rule above).Rule of thumb:
Placement rule:
HttpApi code under src/server, not src/effectsrc/effect should stay focused on runtimes, layers, instance state, and shared Effect plumbingHttpApi slice next to the HTTP boundary it servessrc/server/routes/instance/httpapi/*src/server/routes/control/httpapi/*Suggested file layout for a repeatable spike:
src/server/routes/instance/httpapi/question.ts — contract and handler layer for one route groupsrc/server/routes/instance/httpapi/server.ts — bridged Effect HTTP layer that composes all groupsquestion-httpapi test file on this branchSuggested responsibilities:
question.ts defines the HttpApi contract and HttpApiBuilder.group(...) handlersserver.ts composes all route groups into one HttpRouter.toWebHandler(...) bridge with shared middleware (auth, instance lookup)Each route-group spike should follow the same shape.
HttpApiHttpApiGroupHttpApiBuilder.group(api, groupName, ...)httpapi/server.tsHttpRouter.toWebHandler(...)OPENCODE_EXPERIMENTAL_HTTPAPI flagThe Effect HttpApi layer owns its own auth and instance middleware, but it is currently mounted inside the existing Hono server.
HttpApi layer implements auth as an HttpApiMiddleware.Service using HttpApiSecurity.basicHttpApi is wrapped with .middleware(Authorization) before being servedHttpApi sliceHttpApi layer resolves instance context via an HttpRouter.middleware that reads x-opencode-directory headers and directory query paramsWorkspaceRouterMiddlewareHttpApi handlers yield services from context and assume the correct instance has already been provided400s handled by Effect HttpApi automaticallyThe first slice is successful if:
.zod or clearly temporaryHttpApi contractSchema.Class works well for route DTOs such as Question.Request, Question.Info, and Question.Reply.Question.Answer should stay as schemas and use helpers like withStatics(...) instead of being forced into classes.HttpApi success schema uses Schema.Class, the handler or underlying service needs to return real schema instances rather than plain objects.Schema.Class emits named $ref in OpenAPI — only use it for types that already had .meta({ ref }) in the old Zod schema. Inner/nested types should stay as Schema.Struct to avoid SDK shape changes.HttpRouter.toWebHandler with the shared memoMap from run-service.ts cleanly bridges Effect routes into Hono — one process, one port, shared layer instances.Observability.layer must be explicitly provided via Layer.provideMerge in the routes layer for OTEL spans and HTTP logs to flow. The memoMap deduplicates it with AppRuntime — no extra cost.HttpMiddleware.logger (enabled by default when disableLogger is not set) emits structured Effect.log entries with http.method, http.url, http.status — these flow through OtlpLogger to motel.OPENCODE_EXPERIMENTAL_HTTPAPI flag gates the bridge at the Hono router level — default off, no behavior change unless opted in.Status legend:
bridged - Effect HttpApi slice exists and is bridged into Hono behind the flagdone - Effect HttpApi slice exists but not yet bridgednext - good near-term candidatelater - possible, but not first wavedefer - not a good early HttpApi targetCurrent instance route inventory:
question - bridged
endpoints: GET /question, POST /question/:requestID/reply, POST /question/:requestID/rejectpermission - bridged
endpoints: GET /permission, POST /permission/:requestID/replyprovider - bridged
endpoints: GET /provider, GET /provider/auth, POST /provider/:providerID/oauth/authorize, POST /provider/:providerID/oauth/callbackconfig - bridged (partial)
bridged endpoint: GET /config/providers
later endpoint: GET /config
defer PATCH /config for nowproject - bridged (partial)
bridged endpoints: GET /project, GET /project/current
defer git-init mutation firstworkspace - next
best small reads: GET /experimental/workspace/adaptor, GET /experimental/workspace, GET /experimental/workspace/status
defer create/remove mutations firstfile - later
good JSON-only candidate set, but larger than the current first-wave slicesmcp - later
has JSON-only endpoints, but interactive OAuth/auth flows make it a worse early fitsession - defer
large, stateful, mixes CRUD with prompt/shell/command/share/revert flows and a streaming routeevent - defer
SSE onlyglobal - defer
mixed bag with SSE and process-level side effectspty - defer
websocket-heavy route surfacetui - defer
queue-style UI bridge, weak early HttpApi fitRecommended near-term sequence:
workspace read endpoints (GET /experimental/workspace/adaptor, GET /experimental/workspace, GET /experimental/workspace/status)config full read endpoint (GET /config)file JSON read endpointsmcp JSON read endpointsHttpApi group for a simple JSON route settoWebHandler with shared memoMapOPENCODE_EXPERIMENTAL_HTTPAPI flagGET /provider, OAuth mutations)config providers read endpointproject read endpoints (GET /project, GET /project/current)workspace read endpointsGET /config full read endpointfile JSON read endpointsDo not start with the hardest route file.
If HttpApi is adopted here, it should arrive after the handler body is already Effect-native and after the relevant request / response models have moved to Effect Schema.