ColdBox 8.2.0 is the result of work we started even before 8.1 was released early this year. Several of its headline features, including route-scoped middleware and Server-Sent Events, were incubated for months before shipping. We held them back on purpose: we wanted the APIs settled, the edge cases covered, and the testing story complete before asking you to build on them. They're ready now.
What landed is a meaningful shift in how ColdBox applications are composed:
- Route-scoped middleware, so security, rate limiting, and auditing are declared on the route they protect
- Standards-based HTTP caching, with ETag, Last-Modified, and Cache-Control support in a single call
- First-class Server-Sent Events, with a mock emitter and matchers so streaming endpoints can be integration tested like any other request
- Conversational AI routing and AI Gateways, so your agents can track users and threads, and receive traffic from platforms like Slack with human-in-the-loop approval built in
- A security hardening fix and a performance pass across the renderer, routing hot paths and cold starts with over 30% faster boot-up
This release also continues a direction we set with 8.0 and 8.1: making AI a core capability of the framework, not an add-on. Applications are increasingly expected to talk to models, stream responses, expose tools, and host agents, and teams shouldn't have to build that plumbing themselves. Since 8.1, ColdBox has exposed BoxLang AI as routing primitives: toAi() for serving agents and models, and toMCP() for serving tools. 8.2.0 adds conversational context to toAi(), a new toAiGateway() for inbound platform traffic, and a first-class streaming layer underneath all of it. The goal is simple: building an AI-enabled application in ColdBox should feel as conventional as building a REST API.
By semver it's a minor release, with no breaking API changes (one scheduler timing behavior change is called out below). By scope, it's closer to a major one. For teams running ColdBox in production, the practical outcome is fewer custom layers to maintain, less infrastructure to bolt on, and more of your application's behavior visible in one place.
Rebuilt Documentation
We've also completely rebuilt the ColdBox documentation to be leaner and easier to navigate. It includes new sections like Agentic Development, which covers using AI coding agents with ColdBox: the coldbox ai install CLI tooling, 200+ skills, framework guidelines, MCP documentation servers, and live application introspection through cbMCP. Every feature in this post links to its updated docs page.
Let's get into it.
๐งต Route-Scoped Middleware
The problem: app-wide interceptors are a blunt instrument. Need auth on one admin route? You either wire up a global interceptor with pattern matching, or you scatter if checks across handler actions. Neither scales cleanly, and neither tells you, at a glance, what actually runs for a given URL.
What changed: routes now carry their own middleware chain, declared right where the route is declared. It's not a new subsystem. It reuses the same interception dispatch ColdBox interceptors have always used, just scoped to one route.
Start with a closure
route( "/admin/:action" )
.middleware( ( event, rc, prc ) => {
if ( !auth.isLoggedIn() ) {
event.relocate( "login" );
return true; // stop the remaining middleware for this route
}
} )
.toHandler( "admin" );
Graduate to a real class
Closures are great for prototyping. For anything real, point .middleware() at a WireBox ID. Any object works as long as it has a method named after the point it runs at. No base class, no interface, and full dependency injection:
// models/middleware/RequireLogin.bx
class singleton {
property name="auth" inject="AuthService";
function preProcess( event, rc, prc ){
if ( !auth.isLoggedIn() ) {
event.relocate( "login" );
return true;
}
}
}
// config/Router.bx
route( "/admin/:action" ).middleware( "RequireLogin" ).toHandler( "admin" );
WireBox IDs are resolved on every request, so whatever scope you mapped them with (singleton, prototype, request) is respected.
Before or after the handler
The second argument picks the point: preProcess (the default) or postProcess, for shaping the response after your handler runs:
// Before the handler
route( "/api/orders" ).middleware( "RateLimiter" ).toHandler( "orders" );
// After the handler
route( "/api/reports" ).middleware( "AuditLog", "postProcess" ).to( "reports.index" );
Route middleware runs after the global preProcess announce and before the global postProcess announce. Global interceptors stay the outermost layer, and route-specific work happens closest to the handler.
Stack them
Pass an array and they run in order:
route( "/api/orders" )
.middleware( [ "RateLimiter", "RequireApiKey" ] )
.toHandler( "orders" );
Share them across groups
Attach middleware to a group() and every route inside inherits it, ahead of its own. Nested groups compose outer-first:
group( { pattern : "/api", middleware : [ "RequireApiKey" ] }, () => {
route( "/products" ).toHandler( "products" ); // RequireApiKey
route( "/users" ).middleware( "RateLimiter" ).toHandler( "users" ); // RequireApiKey, RateLimiter
group( { pattern : "/admin", middleware : [ "RequireAdmin" ] }, () => {
route( "/settings" ).toHandler( "settings" ); // RequireApiKey, RequireAdmin
} );
} );
Name your bundles
Repeating the same list everywhere gets old fast. middlewareGroup() registers a named bundle once, and you reference it by name like any other target:
// Declare these at the top of configure()
middlewareGroup( "api", [ "RequireApiKey", "RateLimiter" ] );
middlewareGroup( "admin", [ "RequireLogin", "RequireAdmin", "AuditTrail" ] );
route( "/orders" ).middleware( "api" ).toHandler( "orders" );
group( { pattern : "/admin", middleware : [ "admin" ] }, () => {
route( "/dashboard" ).toHandler( "dashboard" );
} );
Opt out when you need to
withoutMiddleware() lets one route skip what it would otherwise inherit. Match by WireBox ID, by group name (which removes every member that group expanded to), or "*" to strip everything:
group( { pattern : "/api", middleware : [ "api" ] }, () => {
route( "/users" ).toHandler( "users" ); // runs "api"
route( "/health" ).withoutMiddleware( "api" ).toHandler( "health" ); // opts out
route( "/status" ).withoutMiddleware( "*" ).toHandler( "status" ); // runs nothing
} );
Call order doesn't matter. withoutMiddleware() can sit anywhere in the fluent chain.
Three things to know
- Register groups before you reference them. Expansion happens at registration time. A name used before its
middlewareGroup()call is treated as a literal WireBox ID. return truestops the remaining middleware, not the handler. To actually halt the request, do what you'd do in any interceptor:event.relocate(),event.renderData(...).noExecution(), and so on.- It's before/after dispatch, not a wrapping pipeline. One target can't run code both before and after the handler in a single call. For true wrapping (timing a request, catching everything downstream), reach for
aroundHandler.
Why it matters: your routing file becomes the single source of truth for what runs where. Security, rate limiting, auditing: all visible on the route, all reusable, all injectable. No more spelunking through interceptor configs to figure out why /admin/users behaves differently than /admin/health.
๐๏ธ HTTP Caching Primitives: ETag, Last-Modified, Cache-Control
The problem: conditional GET (304 Not Modified) is one of the cheapest performance wins on the web, and almost nobody implements it because doing it by hand means juggling headers, hashes, and status codes in every action.
What changed: it's now one if.
function show( event, rc, prc ){
prc.product = productService.get( rc.id );
if ( event.etag( prc.product.getHash() ) ) {
return; // 304 already sent - nothing left to do
}
event.setView( "products/show" );
}
| Method | Sets | Matches against |
|---|---|---|
event.etag( value, weak=false ) | ETag | If-None-Match |
event.lastModified( value ) | Last-Modified | If-Modified-Since |
event.cacheControl( directives ) | Cache-Control | n/a |
Neither etag() nor lastModified() will ever short-circuit an unsafe method. A 304 in response to a POST would be a protocol violation, so mutating requests always proceed.
cacheControl() builds the header from a struct. true becomes a bare directive, anything else becomes key=value:
event.cacheControl( {
"public" : true,
"max-age" : 60,
"stale-while-revalidate" : 30
} );
// Cache-Control: public, max-age=60, stale-while-revalidate=30
REST handlers get it fluently
function show( event, rc, prc ){
prc.product = productService.get( rc.id );
event.getResponse()
.withETag( prc.product.getHash() )
.withCacheControl( { "private" : true, "max-age" : 300 } )
.setData( prc.product.getMemento() );
}
Or let event caching compute it for you
Already using cache="true"? Add etag or lastModified and ColdBox computes the validator once, at cache-write time, then reuses it on every hit. Zero per-request work:
// BoxLang
@cache( true )
@cacheTimeout( 30 )
@etag( true)
@lastModified( true )
function show( event, rc, prc ) {
prc.entry = entryService.get( rc.entryID );
event.setView( "blog/showEntry" );
}
// CFML
function show( event, rc, prc ) cache="true" cacheTimeout="30" etag="true" lastModified="true" {
prc.entry = entryService.get( rc.entryID );
event.setView( "blog/showEntry" );
}
With no explicit cacheControl, you get a sensible default of private, max-age=<cacheTimeout in seconds>.
Writing your own interceptors? Guard them
If you have a global postProcess that renders unconditionally, check whether the response was already committed by a 304 (or an SSE stream):
function postProcess( event, interceptData, rc, prc ){
if ( event.isNoExecution() ) {
return;
}
// ... your logic
}
Why it matters: browsers and CDNs stop re-downloading content that hasn't changed. Less bandwidth, faster perceived loads, lower server cost, and it costs you one line instead of a caching layer.
๐บ๏ธ Route-Level Cache Rules: withCache()
The same caching power, declared where the URL lives instead of buried on a handler annotation:
route( "/products/:id" )
.withCache( timeout : 30, etag : true, cacheControl : "public, max-age=60" )
.toHandler( "products.show" );
withCache() mirrors every handler cache annotation (timeout, provider, suffix, include, exclude, filter) plus the HTTP primitives. A route that opts in takes full precedence over the handler's annotations. Routes that don't opt in behave exactly as before.
Here's the part a handler annotation could never do. Two routes, one handler, two policies:
// Public catalog: cache aggressively
route( "/catalog/:id" )
.withCache( timeout : 60, etag : true )
.toHandler( "products.show" );
// Admin preview of the same handler: never cache
route( "/admin/preview/:id" )
.middleware( "admin" )
.toHandler( "products.show" );
Notice what just happened. Middleware and caching, side by side, in the route file. Open one file and you know exactly who can hit a URL and how it's cached.
- ๐ Route-Level Caching
๐ก First-Class Server-Sent Events (๐BoxLang)
The problem: live dashboards, progress bars, notification feeds, AI token streaming. Every modern app needs to push data to the browser, and in ColdBox that used to mean reaching outside the framework or leaning on whatever the AI routing layer happened to expose.
What changed: streaming is now a first-class concern. event.sse() takes over the response and hands your callback an emitter:
function ticker( event, rc, prc ){
event.sse( emitter => {
while ( emitter.isOpen() ) {
emitter.send( { "ts" : now() }, "tick" );
sleep( 1000 );
}
} );
}
That's it. Keep-alives are automatic, isOpen() flips to false when the client walks away, and every send method silently no-ops after a disconnect, so your loop never throws on a dead connection.
Stream HTML, not just JSON
The emitter speaks ColdBox. Render views straight into the stream, which pairs beautifully with htmx or any HTML-over-the-wire frontend:
function orderFeed( event, rc, prc ){
event.sse( emitter => {
for ( var order in orderService.watchNew() ) {
if ( !emitter.isOpen() ) break;
emitter.sendView(
view : "orders/_row",
args : { order : order },
event : "order"
);
}
} );
}
The full emitter API: send(), sendData() (through the DataMarshaller: json, xml, text, html), sendView(), sendLayout(), sendError(), sendIf(), comment(), heartbeat(), and close().
One action, two representations
Serve JSON to normal clients and a stream to EventSource clients from the same action. wantsSSE() is true when the client sent Accept: text/event-stream or hit a .sse extension:
function show( event, rc, prc ){
if ( event.wantsSSE() ) {
return event.sse( emitter => {
while ( emitter.isOpen() ) {
emitter.send( reportService.progress( rc.id ), "progress" );
sleep( 500 );
}
} );
}
return reportService.get( rc.id );
}
Routes that always stream
Skip the handler entirely with toSSE():
route( "/events/heartbeat" ).toSSE( ( event, rc, prc, emitter ) => {
while ( emitter.isOpen() ) {
emitter.send( { "ts" : now() }, "heartbeat" );
sleep( 5000 );
}
} );
Control the lifecycle
Three new interception points: preSSEConnection, postSSEConnection (with sentCount and duration), and onSSEError. Reject a connection before it ever opens:
function preSSEConnection( event, interceptData, rc, prc, buffer, data ){
if ( !auth.isLoggedIn() ) {
data.abort = true;
data.statusCode = 401;
}
}
And set app-wide defaults in one place, overridable per call:
this.sse = {
"keepAliveInterval" : 30000, // most proxies idle out at 60s
"retry" : 0, // client reconnect hint in ms
"cors" : "*"
};
๐งช Streams you can actually test
This is the part we're proudest of. Streaming endpoints are notoriously hard to test, so most people don't. We built the SSE abstractions specifically so you can.
Integration tests run under a MockController. When event.sse() detects that, it swaps in a MockSSEEmitter and runs your callback synchronously, recording every frame. Your handler code doesn't change at all. Then you assert with the new toHaveSentSSEEvent() matcher:
it( "streams a countdown", () => {
var event = execute( event : "launch.countdown" );
var stream = event.getPrivateValue( "_sseEmitter" );
expect( stream.getSentCount() ).toBe( 4 );
expect( stream ).toHaveSentSSEEvent( "tick", 3 );
expect( stream ).toHaveSentSSEEvent( "done" );
expect( stream.isClosed() ).toBeTrue();
} );
Want to prove your loop exits cleanly when a client disconnects mid-stream? MockSSEEmitter has simulateDisconnect() for exactly that.
Why it matters: real-time features stop being the untested corner of your codebase. Same lifecycle, same interceptors, same test harness as every other request.
๐ค AI Routing Gets Conversational Context
The problem: every AI chat endpoint needs to know who's talking and which conversation a message belongs to. Everyone ends up rebuilding the same plumbing.
What changed: toAi()'s invoke, stream, and batch sub-routes now resolve userId, conversationId, and threadId from the request body and pass them to your runnable through options:
// POST /api/chat/invoke { "input": "hi", "threadId": "t-123" }
// โ runnable.run( "hi", {}, { userId: "<session id>", threadId: "t-123" } )
// โ { "output": ..., "success": true, "threadId": "t-123" }
userIddefaults to the framework's own session/request tracking identifier. Multi-user isolation for free.conversationIdpasses through only if you send it. No defaults invented behind your back.threadIdis generated if missing and always echoed back: in the JSON body, in anX-Thread-Idheader, and as a leadingevent: threadframe on/stream. That last one matters because browserEventSourceclients can't read response headers. Your frontend grabs the thread ID straight from the stream.
We also corrected the toAi() reference docs, which had drifted from the actual run()/stream()-based IAiRunnable interface. If something felt off before, it wasn't you.
Why it matters: conversational state has nothing to do with your agent logic and everything to do with bookkeeping. ColdBox now does the bookkeeping.
๐ช AI Gateway Routing: toAiGateway()
The problem: toAi() handles a client talking to your agent. toMCP() handles tool calls. But what about the third direction: Slack, Teams, or any platform talking to your agent, on the platform's terms, with its own verification handshake and signature scheme?
What changed: one line mounts everything a BoxLang AI Gateway needs:
route( "/gateways" ).withSSL().toAiGateway( session : "SupportAgentSession" );
| Verb | Pattern | Purpose |
|---|---|---|
POST | {pattern}[/:gateway]/events | An inbound platform event |
GET | {pattern}[/:gateway]/events | The platform's URL verification handshake |
GET | {pattern}/interactions/:requestID | Poll a pending human-in-the-loop approval |
POST | {pattern}/interactions/:requestID/decisions | Submit a human's decision |
GET | {pattern}/info | What this mount serves |
Pin a mount to one platform, or let one mount serve them all:
// Only Slack, on its own URL
route( "/slack" ).withSSL().toAiGateway( "slack", "SupportAgentSession" );
// Every gateway in aiGatewayRegistry(), selected by the :gateway placeholder
route( "/gateways" ).withSSL().toAiGateway( session : "SupportAgentSession" );
The production details are handled for you:
- One URL, two verbs, on purpose. Platforms verify a URL with
GETbefore they'll everPOSTto it. - Instant acks. With a
session, every message is dispatched as an agent turn and acked202immediately. Platform webhooks time out in seconds; agent turns don't. The thread ID comes back in the response andX-Thread-Idso you can correlate the reply. - Verify-only mode. Leave out
sessionand events are verified and parsed only. You decide what happens next. - Signatures first. The gateway verifies signatures before anything is parsed or dispatched. Forged payloads never touch your code.
Pair it with the middleware you just learned about and you have an audited, rate-limited, human-approved agent endpoint in a handful of lines.
Why it matters: this is what turns "I built a chatbot" into "I built an agent that lives inside the tools my team already uses," with human-in-the-loop approval built into the routing layer instead of bolted on.
BoxLang + bx-ai only.
๐ Hardened HTT Method Spoofing
The _method form field (how HTML forms fake PUT/PATCH/DELETE) was previously honored on any request. A plain GET with ?_method=DELETE was treated as a DELETE. That's a CSRF-style hole: a link, an <img> tag, a crawler, or a browser prefetch could trigger a destructive action.
As of 8.2.0, _method is only honored when the real transport-level request is a POST. Need the raw verb? It's one call:
event.getOriginalHTTPMethod(); // "POST", even when _method=DELETE was applied
Why it matters: a real attack surface closed with zero changes on your part. Upgrade and you're covered.
๐ HTTP Method Spoofing
โก Faster, Without Touching Your Code
viewDiscoveryCaching(on by default, independent ofviewCaching) caches the filesystem lookups that locate views and layouts. Every render benefits, whether or not you cache view output.- Hot-path cleanup in
HandlerService,RoutingService, andRouter: a per-request settings lookup now cached at configuration, a redundant filesystem check removed from view dispatch, and route response placeholders parsed once at registration instead of by regex on every request. - Scheduler and startup overhaul for task registration and interception hot paths.
- Short-circuit detection.
announce()now returnstruewhen an interceptor short-circuited the chain. Previously undetectable:
if ( controller.getInterceptorService().announce( "onOrderPlaced", data ) ) {
// an interceptor returned true and consumed this announcement
}
Why it matters: free speed is the best kind. Update, ship nothing new, and your app is faster.
โฐ Scheduler Fixes (Read This If You Use every() + between())
- Tasks combining
every()withstartOnTime()/between()now wait for the next aligned period boundary instead of firing immediately on registration or on every restart. This is a behavior change if you relied on the old "fire immediately" semantics. - Tasks combining
withNoOverlaps()withbetween()/startOnTime()no longer misread theirspacedDelayunit. A 1-minute task no longer re-fires every second. onOneServer()locks are now sized to the task's real cadence.
๐ Full Null-Runtime Support
ColdBox now runs cleanly under enableNullSupport turned on. If you've been holding off on flipping that switch, 8.2.0 closes the gaps.
Also in This Release
- Fluent routes preserve their domain when grouped or nested.
Response.setData()gainsmessageandlocationarguments, plus new fluent builders.isAjaxdetects Fetch API metadata, not justX-Requested-With.- New
bodyargument on test request methods for mocking incoming request bodies. - WireBox: explicit mappings no longer get deleted when their first metadata lookup fails.
- CacheBox:
BoxLangProviderwas expiring objects 60x too soon (minutes vs seconds). Fixed. - LogBox: the root appender could fire before LogBox finished initializing. Fixed.
- Deprecation markers added for legacy tools and Adobe 2023 static scope, ahead of v9.
Every ticket and PR: What's New With 8.2.0.
๐ฑ Introducing cbGenesis: Start Production-Ready
One more thing. Alongside 8.2.0 we're introducing cbGenesis, a brand new, production-ready ColdBox application template for BoxLang.
Every new app starts the same way: login, registration, password resets, roles, permissions, an admin panel, emails, migrations, tests. Weeks of work before you write a single line of your product. cbGenesis ships all of it, built the way we'd build it for our own enterprise clients.
What you get out of the box:
- Auth & RBAC, batteries included. Session auth,
@securedhandlers, CSRF rotation, JWT support, and aresource:actionpermission model with roles and permissions admin screens. - Single Sign-On and Passkeys. Powered by cbSSO and its wide range of providers. Google comes pre-wired as a working reference, so adding any other provider you need follows the same pattern. Includes account linking, domain-based auto-provisioning, and passwordless WebAuthn sign-in.
- Audit log and rate limiting. Every sign-in, sign-out, and authorization failure lands in a searchable audit trail, and an IP-based limiter throttles login, registration, and reset abuse.
- API tokens. Hashed, per-user personal access tokens with expiration and a scheduled purge job.
- Modern structure. App code in
app/, fully separated from thepublic/webroot. Secure by default. - Hibernate ORM + qb.
BaseEntity/BaseServiceconventions, migrations and seed data, and qb when raw SQL does it better. - Alpine.js + Bootstrap 5 UI. Server-rendered views, light/dark theming, and a Vite pipeline with HMR.
- Email workflows. Password reset, verification, invitations, and welcome templates.
- A real test suite. Unit specs for every entity and service, plus integration specs over real HTTP requests.
- Ready to ship. A go-live checklist, Docker support, and your choice of CommandBox or the BoxLang MiniServer.
Why it matters: you skip straight to building the part of your app that's actually yours, on a foundation that already has security, auditing, and tests done right.
Get started at cbgenesis.coldbox.org or on GitHub. A dedicated cbGenesis release post, with a full tour, is coming very soon.
What's Next: bxAgents ๐ฎ
AI Gateway routing isn't a one-off. It's a foundation for something bigger: bxAgents, our new conventions-based AI agent framework, powered by ColdBox.
ColdBox gave you conventions for building web apps. bxAgents gives you conventions for building agents: the same productive, "it just works" experience, applied to autonomous AI. Middleware, streaming, conversational context, gateways: you just saw the building blocks.
More very soon at bxAgents.ai.
Upgrade Today
box update coldbox
Questions, bugs, or that feature idea you've been sitting on? Find us in the community.
Now go build something groundbreaking. ๐
Add Your Comment