Blog

Luis Majano

September 05, 2026

Spread the word


Share your thoughts

HumanInTheLoopMiddleware used to do everything itself: decide which tool calls needed approval, present the request, and wait for a decision. In 3.4.0, that logic has been pulled apart into a real subsystem, and the piece developers will feel the most is that a human's "always allow this" now actually means always.

The extraction

HITL now lives in a dedicated models/hitl/ package with two clear responsibilities split apart:

  • An IApprovalPolicy decides whether a given tool call needs a human to sign off.
  • A HumanInteractionCoordinator owns presenting the request through an attached gateway and resolving the decision.

HumanInTheLoopMiddleware is now a thin adapter over the two. The simplest case still looks exactly like before:

import bxModules.bxai.models.middleware.core.HumanInTheLoopMiddleware;

// Match by tool name, the default policy
hitl = new HumanInTheLoopMiddleware( toolsRequiringApproval: [ "deleteRecord" ] )

But now you can swap in any policy that implements IApprovalPolicy:

hitl = new HumanInTheLoopMiddleware(
    policy : new RiskLevelApprovalPolicy( minLevel: "high" ),
    gateway: aiGateway( "http" )
)

agent = aiAgent( middleware: [ hitl ], checkpointer: aiMemory( "cache" ) )

Built-in policies cover the common shapes out of the box: ToolNameApprovalPolicy (the default), RiskLevelApprovalPolicy, AnnotationApprovalPolicy, CallbackApprovalPolicy, and CompositeApprovalPolicy for combining several. Or write your own, an IApprovalPolicy is just a decision function with a name.

Durable grants, the actual headline feature

Before 3.4.0, an "always approve this" decision only lived as long as the process did. Restart your app, and the human gets asked again. That's fixed with a pluggable IDecisionStore:

// Explicit store
store = aiDecisionStore( "jdbc", { datasource: "myDSN", table: "ai_decisions" } )
hitl  = new HumanInTheLoopMiddleware( toolsRequiringApproval: [ "placeOrder" ], decisionStore: store )

Three store implementations ship: cache, jdbc, and file. Or configure the application-wide default once and every HITL middleware in the app shares it:

// boxlang.json
{
  "modules": {
    "bxai": {
      "settings": {
        "hitl": {
          "decisionStore": {
            "provider": "cache",
            "config": {}
          }
        }
      }
    }
  }
}

That last part is by design, not a limitation: an application never attaches more than one HITL middleware at a time, so there's exactly one source of truth for "what has this human already approved."

The five decisions

Every HITL flow resolves to one of: approve, approve_always, approve_session, reject, edit, cancel. approve_always and approve_session are the two that now persist through the decision store instead of evaporating on restart.

Migration, if you're already using HITL

Existing mode: "cli" / mode: "web" configuration keeps working exactly as it did. If you're attaching a specific gateway anyway, prefer gateway: going forward, an unrecognized mode now falls back to a CLI gateway with a console warning instead of silently pretending to be "cli".

// Still works, unchanged
agent = aiAgent(
    tools     : [ deleteRecordTool ],
    middleware: [ new HumanInTheLoopMiddleware( mode: "web", toolsRequiringApproval: [ "deleteRecord" ] ) ],
    checkpointer: aiMemory( "cache" )
)

Why the split matters

Separating "should this be approved" from "how do we ask" means you can change either independently. Swap your approval policy from tool-name matching to risk-level scoring without touching how approvals get presented. Swap your gateway from CLI to HTTP without touching your approval logic. And with durable grants, the humans approving your agents' actions only have to make each decision once.

Next in the series: what happens when a single turn asks for approval on multiple tool calls at once, and why that used to silently drop half of them.

Docs: Middleware, Human-in-the-Loop

Add Your Comment

Recent Entries

BoxLang 3.4 Blog Series Part I: Gateways, One Interface, Any Platform

BoxLang 3.4 Blog Series Part I: Gateways, One Interface, Any Platform

Every AI agent that touches something real eventually needs a human in the loop. Someone has to approve the delete, confirm the wire transfer, or just say "go ahead" before a tool call runs. The question BoxLang AI 3.4.0 answers is: approve it how? A terminal prompt? A webhook? A Slack button?

Luis Majano
Luis Majano
September 04, 2026
BoxLang 1.17 Series Part1 : Module Inception

BoxLang 1.17 Series Part1 : Module Inception

BoxLang has always been an extensible language. As of 1.17.0, it is a hierarchically extensible one. Every module ecosystem you have worked in is flat. You declare a list of dependencies and something outside the language, a package manager or a build tool, resolves and downloads and orders them. The language itself has no opinion about the shape of the graph. It sees a list, not a tree.

Luis Majano
Luis Majano
September 04, 2026