Writing a brick
A brick is what other frameworks call a plugin. Bricks build the oven: add one and a capability appears on the request context, fully typed. Take it away and using it is a compile error.
This page is the contract, for anyone writing one. For the bricks Oven ships and how to use them, see the catalogue.
The name is the only piece of invented vocabulary in Oven, and it is deliberate — a brick is a specific thing with a specific contract, not the grab-bag “plugin” has become elsewhere.
A brick’s setup() runs once at boot and returns the thing it provides. That value becomes
ctx.<name>, and its type flows through .use() into every handler.
const app = createApp() .use(storage({ driver: 's3', bucket: 'uploads' })) .use(queue({ driver: 'redis' }))
app.post('/upload', async (ctx) => { const { url } = await ctx.storage.upload('key', file) // typed await ctx.queue.dispatch('resize', { url }) // typed return { url }})Leave the queue brick out and ctx.queue is a compile error, not a crash at 3am. That is
the whole point, and the test suite asserts it: registering the brick makes the
@ts-expect-error on that line unnecessary, which fails the build.
Two kinds of contribution
Section titled “Two kinds of contribution”A brick contributes in two places, and the difference is the whole reason ctx.user can exist:
| Runs | Contributes | Cost per request | |
|---|---|---|---|
setup() |
once, at boot | a shared service | none — it lives on the prototype |
request() |
every request | per-request state | only what you compute |
const auth = { name: 'auth', setup: () => createClerkClient(...), // shared, once request: async (ctx) => ({ // per request user: await verifyToken(ctx.token), }),}
app.get('/me', (ctx) => { ctx.auth.users.get(...) // the shared client, typed ctx.user?.id // per-request state, typed})request() runs after routing, so ctx.params is available, and before middleware, the
handler and validation. Throwing rejects the request — which is how an auth brick turns a bad
token into a 401 without a handler ever seeing it.
It is also handed the matched route:
request: (ctx, route) => { if (route.schema?.auth) requireSignedIn(ctx)}Core never interprets auth: 'admin' on a route. It carries it, and the brick decides what it
means — which is what keeps auth replaceable instead of built in.
Writing one
Section titled “Writing one”import type { Brick } from '@theoven/core'
export function cache(options: { ttl: number }): Brick<'cache', CacheClient> { return { name: 'cache', setup: async () => connect(options), onShutdown: (client) => client.disconnect(), }}| Field | Purpose |
|---|---|
name |
the property added to the context |
setup(context) |
builds the value; runs once, at boot |
dependsOn |
brick names that must be set up first |
onRequest(ctx) |
per-request work, such as loading a session |
onShutdown(value) |
release pools, workers, connections |
Shared versus per-request
Section titled “Shared versus per-request”setup()’s return value lives on the context prototype, so it is shared across requests and
costs nothing per request. Anything genuinely per-request — a session, a transaction — belongs
in onRequest, which runs with the context in hand.
Dependencies
Section titled “Dependencies”{ name: 'auth', dependsOn: ['db'], setup: (context) => makeAuth(context.resolved.db),}Declaring the dependency rather than relying on registration order means the auth brick finds the database however the user chose to write their config. A cycle throws at boot, naming both bricks — it cannot be resolved at runtime, so failing early beats deadlocking.
Contributing routes
Section titled “Contributing routes”setup: (context) => { context.route('GET', '/_docs', () => renderDocs()) return { mounted: true }}This is how /auth/* and the docs UI will mount themselves.
raw — the escape hatch
Section titled “raw — the escape hatch”Every first-party service exposes the thing underneath it:
ctx.storage.raw // the Bun.S3Client, or the directory pathctx.queue.raw // the Bun.RedisClient, or the SQL clientctx.cache.raw // likewisectx.auth // the provider itselfctx.db // not an escape hatch — `ctx.db` *is* the Drizzle client (D16)This is what makes a small contract acceptable. A contract is only worth having if it is the
genuine intersection of its implementations — the moment it grows a method that one driver has
to fake, it has started lying. So anything a single driver can do and the others cannot stays off
the contract, and raw is how you reach it:
// S3 can copy an object server-side. Disk, Bunny and ImageKit cannot, so `copy` is not on the// contract — and reaching through `raw` is how you use it anyway.const s3 = ctx.storage.raw as Bun.S3Clientawait s3.file('archive/report.pdf').write(s3.file('reports/2026-q3.pdf'))user.raw is the same idea with one difference: it is typed per adapter rather than unknown,
because an auth provider’s shape is known at the point you register it. See
user.raw.
Declared capabilities, when it is not a one-off
Section titled “Declared capabilities, when it is not a one-off”raw is for reaching past the contract occasionally. When a capability is real but not universal,
the answer is to declare it and check at boot rather than to make everyone reach:
if (!ctx.storage.canPresign) throw new ServiceUnavailable('Direct upload is not configured.')That is D19, and it is why auth-clerk can say out loud that it cannot sign anyone out instead of
offering a signOut() that quietly does nothing.
Configuration files
Section titled “Configuration files”defineConfig puts the same thing in one declarative file:
export default defineConfig({ trustProxy: 1, cookies: { secret: process.env.COOKIE_SECRET }, bricks: [storage({ driver: 's3' }), queue({ driver: 'redis' })],})Errors caught at boot
Section titled “Errors caught at boot”- A duplicate brick name.
- A name that collides with a built-in context property, which would shadow the real thing in a way that surfaces far from the brick responsible.
- A missing or circular dependency.
- Registering a brick after the first request, since setup runs at boot.