Blog

Luis Majano

September 23, 2026

Spread the word


Share your thoughts

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 true stops 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" );
}
MethodSetsMatches against
event.etag( value, weak=false )ETagIf-None-Match
event.lastModified( value )Last-ModifiedIf-Modified-Since
event.cacheControl( directives )Cache-Controln/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.

๐Ÿ“ก 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" }
  • userId defaults to the framework's own session/request tracking identifier. Multi-user isolation for free.
  • conversationId passes through only if you send it. No defaults invented behind your back.
  • threadId is generated if missing and always echoed back: in the JSON body, in an X-Thread-Id header, and as a leading event: thread frame on /stream. That last one matters because browser EventSource clients 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" );
VerbPatternPurpose
POST{pattern}[/:gateway]/eventsAn inbound platform event
GET{pattern}[/:gateway]/eventsThe platform's URL verification handshake
GET{pattern}/interactions/:requestIDPoll a pending human-in-the-loop approval
POST{pattern}/interactions/:requestID/decisionsSubmit a human's decision
GET{pattern}/infoWhat 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 GET before they'll ever POST to it.
  • Instant acks. With a session, every message is dispatched as an agent turn and acked 202 immediately. Platform webhooks time out in seconds; agent turns don't. The thread ID comes back in the response and X-Thread-Id so you can correlate the reply.
  • Verify-only mode. Leave out session and 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 of viewCaching) 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, and Router: 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 returns true when 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() with startOnTime()/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() with between()/startOnTime() no longer misread their spacedDelay unit. 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() gains message and location arguments, plus new fluent builders.
  • isAjax detects Fetch API metadata, not just X-Requested-With.
  • New body argument on test request methods for mocking incoming request bodies.
  • WireBox: explicit mappings no longer get deleted when their first metadata lookup fails.
  • CacheBox: BoxLangProvider was 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.

cbGenesis: production-ready ColdBox starter template for BoxLang

What you get out of the box:

  • Auth & RBAC, batteries included. Session auth, @secured handlers, CSRF rotation, JWT support, and a resource:action permission 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 the public/ webroot. Secure by default.
  • Hibernate ORM + qb. BaseEntity/BaseService conventions, 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

Recent Entries

Assert Like You Mean It, TestBox 7.1 Part 1: Set Expectations

Assert Like You Mean It, TestBox 7.1 Part 1: Set Expectations

If you have ever tested that two collections contain "the same stuff, order doesn't matter," you already know the workaround: sort both arrays, compare them element by element, and hope nobody adds a duplicate. It works, but it isn't what you meant to write. You meant "these are the same set."

Luis Majano
Luis Majano
September 23, 2026
Is Your QA Process Ready for Your Next ColdFusion or BoxLang Release?

Is Your QA Process Ready for Your Next ColdFusion or BoxLang Release?

As applications continue to evolve, QA can easily become reactive. Teams may test late in the development process, focus only on happy-path scenarios, repeat manual checks, or rely on AI-generated changes without fully validating the results. โ€‹ These gaps can create risks that are not always visible until they affect users, disrupt operations, or require costly fixes in production.

Maria Jose Herrera
Maria Jose Herrera
September 21, 2026
Your Development Team Is at Capacity. Hereโ€™s How to Keep Critical Work Moving!

Your Development Team Is at Capacity. Hereโ€™s How to Keep Critical Work Moving!

The problem is not that your team lacks priorities.It is that maintenance, delivery, and long-term improvement are competing for the same people.

For companies running business-critical CFML, ColdFusion, or BoxLang applications, that pressure can be especially difficult to solve. Experienced developers are not always easy to hire quickly, application knowledge may be concentrated in one or two people, and a generalist may need significant context before taking ownership of the work. A permanent hire can also require a significant investment in recruiting, compensation, onboarding, and long-term capacity even when the immediate need is a specific project, urgent maintenance, or a period of transition.

Maria Jose Herrera
Maria Jose Herrera
September 18, 2026