perldelta for B::COP::label, B::PVOP::pv utf8 fixes
B: honor the UTF-8-ness of the pv in B::PVOP::pv
B: honor the UTF-8-ness of the label in B::COP::label Fixes #24040
Provide `B::UNOP_AUX::string` support for `OP_MULTIPARAM` `OP_MULTIPARAM` now handles all of the processing for subroutine signatures. It is useful to show a summary of its contents in the output of `B::Deparse`. The output is now, for example: 2 <+> multiparam(2 $x,$y) ->3 to indicate two mandatory parameters being stored into `$x` and `$y`, where before it would have been simply 2 <+> multiparam() ->3 Fixes https://github.com/Perl/perl5/issues/24064
Some minor whitespace fixes in ext/B/B.xs

When I first introduced Marlin, it seemed the only OO framework which could beat its constructor in speed was the one generated by the new Perl core class keyword. Which seems fair, as that’s implemented in C and is tightly integrated with the Perl interpreter. However, I’m pleased to say that Marlin’s constructors are now faster.
(Though also I forgot to include Mouse in previous benchmarks, so I’ve rectified that now.)
Rate Plain Tiny Moo Moose Core Marlin Mouse Plain 1357/s -- -1% -48% -55% -73% -77% -78% Tiny 1374/s 1% -- -48% -54% -72% -77% -78% Moo 2617/s 93% 91% -- -13% -47% -56% -58% Moose 3001/s 121% 118% 15% -- -39% -50% -52% Core 4943/s 264% 260% 89% 65% -- -17% -21% Marlin 5976/s 340% 335% 128% 99% 21% -- -4% Mouse 6237/s 359% 354% 138% 108% 26% 4% --
The main way I’ve squeezed out a bit of improved performance is by improving how Class::XSConstructor keeps its metadata.
Previously, if you called Local::Person->new(), the XS constructor would look up the list of supported attributes for the class in @Local::Person::__XSCON_HAS and loop through that array to initialize each attribute like "name", "age", etc. If the attribute had a type constraint, it would need to fetch the coderef to validate the value from $Local::Person::__XSCON_ISA{"name"}, and so on. All these involved looking things up in the class’s stash, which isn’t exactly slow when done via XS, but could be faster.
I’ve changed it so that the first time the constructor is called, the XS code pulls together all the data it needs into C structs.
typedef struct { char *name; I32 flags; char *init_arg; char **aliases; I32 num_aliases; SV *default_sv; SV *trigger_sv; CV *check_cv; CV *coercion_cv; } xscon_param_t; typedef struct { char *package; bool is_placeholder; xscon_param_t *params; I32 num_params; CV **build_methods; I32 num_build_methods; bool strict_params; char **allow; I32 num_allow; } xscon_constructor_t;
Rather than having to deal with attribute names being Perl SVs, they’re just simple C strings (char*).
The flags field does a lot of heavy lifting. It is a bit field with booleans indicating whether an attribute is required or optional, whether it should be a weaken reference, and other features. A lot of common defaults (attributes which default to common values like undef, true, false, 0, 1, the empty string, an empty arrayref, or an empty hashref) and common type constraints (Str, Num, Int, ArrayRef, etc) are also encoded into the flags field, so the constructor can often skip even having to look at default_sv and check_cv.
At the same time, the number of features Class::XSConstructor supports has increased, so Marlin now never needs to fall back to generating Pure Perl constructors. (The code for generating Perl constructors has now been deleted!)
A second trick is one I learned from Mouse in how it implements its strict constructor check. As a reminder, a strict constructor check is like the ones implemented by MooseX::StrictConstructor, MooX::StrictConstructor, and MouseX::StrictConstructor, along these lines:
sub new { # Unpack @_ my $class = shift; my %args = ( @_ == 1 and ref($_[0]) eq 'HASH' ) ? %{+shift} : @_; # Create new object my $object = bless( {}, $class ); # Initialize each attribute if ( exists $args{name} ) { $object->{name} = $args{name}; } if ( exists $args{date} ) { $object->{date} = $args{date}; } # Strict constructor check for my $key ( %args ) { die "Unrecognized key: $key" unless $key =~ /^(name|date)$/; } return $object; }
Strict constructors are a really useful feature as a protection against mistyped attributes. But they do come with a speed penalty, which I guess is why Moose and Moo don’t have this feature built in. (Mouse does actually have the feature built in, but requires an extension (MouseX::StrictConstructor) to toggle it on.)
Mouse’s strict constructor check has virtually zero performance impact. I took a look at the source code to figure out how, and it is pretty smart. It just counts the number of arguments the constructor has used to initialize attributes, and only bothers with the strict constructor check if the total number of arguments is greater than that. Something like this:
sub new { # Unpack @_ my $class = shift; my %args = ( @_ == 1 and ref($_[0]) eq 'HASH' ) ? %{+shift} : @_; # Create new object my $object = bless( {}, $class ); my $used_keys = 0; # Initialize each attribute if ( exists $args{name} ) { $object->{name} = $args{name}; $used_keys++; } if ( exists $args{date} ) { $object->{date} = $args{date}; $used_keys++; } # Strict constructor check if ( keys(%args) > $used_keys ) { for my $key ( %args ) { die "Unrecognized key: $key" unless $key =~ /^(name|date)$/; } } return $object; }
Genius!
With these changes, Marlin is now significantly faster than the Perl core class keyword.
Mouse still has around 10% faster accessors than Marlin, which I think might be largely down to having an integrated type system allowing pure C function calls for type constraints instead of needing to use call_sv to call an XS or Perl type check function.
Marlin does however beat Mouse significantly (around 70% faster) when it comes to delegated methods. Things like:
use v5.36; package API_Client { use Marlin -modifiers, _log => { isa => 'ArrayRef[HashRef]', default => [], handles_via => 'Array', handles => { add_to_log => 'push', responses => 'all', }, }, ua => { isa => 'HTTP::Tiny', default => sub { HTTP::Tiny->new }, handles => { http_get => 'get', http_post => 'post', }, }; around 'http_get', 'http_post' => sub ( $next, $self, @args ) { my $response = $self->$next( @args ); $self->add_to_log( $response ); return $response; }; } my $client = API_Client->new; $client->http_get( ... ); $client->http_get( ... ); $client->http_get( ... ); my @responses = $client->responses;
Marlin outperforms all other OO frameworks in this kind of method.
If you want a fast, concise OO framework, consider using Marlin.
When I first introduced Marlin, it seemed the only OO framework which could beat its constructor in speed was the one generated by the new Perl core class keyword. Which seems fair, as that’s implemented in C and is tightly integrated with the Perl interpreter. However, I’m pleased to say that Marlin’s constructors are now faster.
(Though also I forgot to include Mouse in previous benchmarks, so I’ve rectified that now.)
Rate Plain Tiny Moo Moose Core Marlin Mouse
Plain 1357/s -- -1% -48% -55% -73% -77% -78%
Tiny 1374/s 1% -- -48% -54% -72% -77% -78%
Moo 2617/s 93% 91% -- -13% -47% -56% -58%
Moose 3001/s 121% 118% 15% -- -39% -50% -52%
Core 4943/s 264% 260% 89% 65% -- -17% -21%
Marlin 5976/s 340% 335% 128% 99% 21% -- -4%
Mouse 6237/s 359% 354% 138% 108% 26% 4% --
The main way I’ve squeezed out a bit of improved performance is by improving how Class::XSConstructor keeps its metadata.
Previously, if you called Local::Person->new(), the XS constructor would look up the list of supported attributes for the class in @Local::Person::__XSCON_HAS and loop through that array to initialize each attribute like "name", "age", etc. If the attribute had a type constraint, it would need to fetch the coderef to validate the value from $Local::Person::__XSCON_ISA{"name"}, and so on. All these involved looking things up in the class’s stash, which isn’t exactly slow when done via XS, but could be faster.
I’ve changed it so that the first time the constructor is called, the XS code pulls together all the data it needs into C structs.
typedef struct {
char *name;
I32 flags;
char *init_arg;
char **aliases;
I32 num_aliases;
SV *default_sv;
SV *trigger_sv;
CV *check_cv;
CV *coercion_cv;
} xscon_param_t;
typedef struct {
char *package;
bool is_placeholder;
xscon_param_t *params;
I32 num_params;
CV **build_methods;
I32 num_build_methods;
bool strict_params;
char **allow;
I32 num_allow;
} xscon_constructor_t;
Rather than having to deal with attribute names being Perl SVs, they’re just simple C strings (char*).
The flags field does a lot of heavy lifting. It is a bit field with booleans indicating whether an attribute is required or optional, whether it should be a weaken reference, and other features. A lot of common defaults (attributes which default to common values like undef, true, false, 0, 1, the empty string, an empty arrayref, or an empty hashref) and common type constraints (Str, Num, Int, ArrayRef, etc) are also encoded into the flags field, so the constructor can often skip even having to look at default_sv and check_cv.
At the same time, the number of features Class::XSConstructor supports has increased, so Marlin now never needs to fall back to generating Pure Perl constructors. (The code for generating Perl constructors has now been deleted!)
A second trick is one I learned from Mouse in how it implements its strict constructor check. As a reminder, a strict constructor check is like the ones implemented by MooseX::StrictConstructor, MooX::StrictConstructor, and MouseX::StrictConstructor, along these lines:
sub new {
# Unpack @_
my $class = shift;
my %args = ( @_ == 1 and ref($_[0]) eq 'HASH' ) ? %{+shift} : @_;
# Create new object
my $object = bless( {}, $class );
# Initialize each attribute
if ( exists $args{name} ) {
$object->{name} = $args{name};
}
if ( exists $args{date} ) {
$object->{date} = $args{date};
}
# Strict constructor check
for my $key ( %args ) {
die "Unrecognized key: $key" unless $key =~ /^(name|date)$/;
}
return $object;
}
Strict constructors are a really useful feature as a protection against mistyped attributes. But they do come with a speed penalty, which I guess is why Moose and Moo don’t have this feature built in. (Mouse does actually have the feature built in, but requires an extension (MouseX::StrictConstructor) to toggle it on.)
Mouse’s strict constructor check has virtually zero performance impact. I took a look at the source code to figure out how, and it is pretty smart. It just counts the number of arguments the constructor has used to initialize attributes, and only bothers with the strict constructor check if the total number of arguments is greater than that. Something like this:
sub new {
# Unpack @_
my $class = shift;
my %args = ( @_ == 1 and ref($_[0]) eq 'HASH' ) ? %{+shift} : @_;
# Create new object
my $object = bless( {}, $class );
my $used_keys = 0;
# Initialize each attribute
if ( exists $args{name} ) {
$object->{name} = $args{name};
$used_keys++;
}
if ( exists $args{date} ) {
$object->{date} = $args{date};
$used_keys++;
}
# Strict constructor check
if ( keys(%args) > $used_keys ) {
for my $key ( %args ) {
die "Unrecognized key: $key" unless $key =~ /^(name|date)$/;
}
}
return $object;
}
Genius!
With these changes, Marlin is now significantly faster than the Perl core class keyword.
Mouse still has around 10% faster accessors than Marlin, which I think might be largely down to having an integrated type system allowing pure C function calls for type constraints instead of needing to use call_sv to call an XS or Perl type check function.
Marlin does however beat Mouse significantly (around 70% faster) when it comes to delegated methods. Things like:
use v5.36;
package API_Client {
use Marlin
-modifiers,
_log => {
isa => 'ArrayRef[HashRef]',
default => [],
handles_via => 'Array',
handles => {
add_to_log => 'push',
responses => 'all',
},
},
ua => {
isa => 'HTTP::Tiny',
default => sub { HTTP::Tiny->new },
handles => {
http_get => 'get',
http_post => 'post',
},
};
around 'http_get', 'http_post' => sub ( $next, $self, @args ) {
my $response = $self->$next( @args );
$self->add_to_log( $response );
return $response;
};
}
my $client = API_Client->new;
$client->http_get( ... );
$client->http_get( ... );
$client->http_get( ... );
my @responses = $client->responses;
Marlin outperforms all other OO frameworks in this kind of method.
If you want a fast, concise OO framework, consider using Marlin.
So over the past few days I've built a new addition to the Perl ecosystem: the Claude Agent SDK. It's a library that brings the agentic capabilities of Claude Code into your Perl applications.
At its core, the SDK enables you to build AI agents that can read files, run shell commands, search the web, edit code, and interact with external systems. All orchestrated from familiar Perl code. Whether you're automating code reviews, building intelligent DevOps tooling, or integrating AI capabilities into legacy systems, this SDK provides the foundation you need.
The architecture is built around a streaming JSON Lines protocol (using my JSON::Lines module) that communicates with the Claude Code CLI, supporting both synchronous operations and fully asynchronous patterns via IO::Async and Future::AsyncAwait. Although we send valid JSON lines, the CLI doesn't always return valid JSON lines, so some extension to my module was needed to handle malformed responses gracefully. Here's what a simple interaction looks like:
use Claude::Agent qw(query);
use Claude::Agent::Options;
my $options = Claude::Agent::Options->new(
allowed_tools => ['Read', 'Glob', 'Grep'],
permission_mode => 'bypassPermissions',
);
my $iter = query(
prompt => "What files in ./lib need the most refactoring?",
options => $options,
);
while (my $msg = $iter->next) {
if ($msg->isa('Claude::Agent::Message::Result')) {
print $msg->result;
last;
}
}
The real power emerges when you explore the SDK's advanced features: custom MCP tools that can run directly in your Perl process with full access to your application state, a subagent system for spawning specialised AI workers with isolated contexts, session management for resuming or forking conversations, and structured output with JSON Schema validation for automation-ready responses.
The SDK is complemented by two separate distributions I wrote that showcase what's possible: a Code Review module for AI-powered analysis with severity-based issue detection and Perlcritic integration, and a Code Refactor module that implements an automated review-fix-repeat loop until your codebase is clean.
Let's dive into how it all works.
Custom MCP Tools That Run in Your Process
One of the most powerful features of the Claude Agent SDK is the ability to create custom MCP tools that execute directly in your Perl process. Unlike external MCP servers that run as separate services, SDK tools have full access to your application's state: your database connections, configuration, session data, and any Perl modules you're already using.
This architecture enables significant functional extensibility. To permit Claude to execute queries against production databases, retrieve customer records, or access inventory data, these operations can be exposed as callable tools within the conversational interface. All tool invocations adhere to JSON Schema validation, ensuring type safety and structural integrity throughout the execution pipeline.
You define a tool with four components: a name, a description (which helps Claude understand when to use it), an input schema (JSON Schema defining the parameters), and a handler (your Perl code that does the actual work):
use Claude::Agent qw(tool create_sdk_mcp_server);
my $find_user = tool(
'find_user', # Tool name
'Find a user by their email address', # Description for Claude
{ # JSON Schema for inputs
type => 'object',
properties => {
email => {
type => 'string',
description => 'Email address to search for'
},
},
required => ['email'],
},
sub { # Handler (runs in your process!)
my ($args) = @_;
# Your code here with full access to application state
return {
content => [{ type => 'text', text => 'Result goes here' }],
};
}
);
The magic is in that handler. It's not running in some sandboxed external process. It's running right in your Perl application, with access to everything you've already set up. Let's build a complete database query tool to see this in action:
#!/usr/bin/env perl
use 5.020;
use strict;
use warnings;
use Claude::Agent qw(query tool create_sdk_mcp_server);
use Claude::Agent::Options;
use IO::Async::Loop;
use DBI;
# Your existing database connection. The tool handler can use this directly
my $dbh = DBI->connect(
'dbi:SQLite:customers.db',
'', '',
{ RaiseError => 1, AutoCommit => 1 }
);
# Tool 1: Find a customer by email
my $find_customer = tool(
'find_customer',
'Look up a customer record by email address. Returns their name, plan, and signup date.',
{
type => 'object',
properties => {
email => {
type => 'string',
description => 'Customer email to search for'
},
},
required => ['email'],
},
sub {
my ($args) = @_;
# Direct database access with no external API, no serialisation overhead
my $customer = $dbh->selectrow_hashref(
'SELECT name, email, plan, created_at FROM customers WHERE email = ?',
undef,
$args->{email}
);
if ($customer) {
return {
content => [{
type => 'text',
text => sprintf(
"Found customer: %s <%s>\nPlan: %s\nMember since: %s",
$customer->{name},
$customer->{email},
$customer->{plan},
$customer->{created_at}
),
}],
};
}
return {
content => [{
type => 'text',
text => "No customer found with email: $args->{email}"
}],
};
}
);
# Tool 2: Get aggregate statistics
my $customer_stats = tool(
'customer_stats',
'Get statistics about customers, optionally filtered by plan type',
{
type => 'object',
properties => {
plan => {
type => 'string',
enum => ['free', 'pro', 'enterprise'],
description => 'Filter by plan type (optional)'
},
},
required => [], # No required params so Claude can call this with no arguments
},
sub {
my ($args) = @_;
my ($sql, @bind);
if ($args->{plan}) {
$sql = 'SELECT COUNT(*) as count, plan FROM customers WHERE plan = ? GROUP BY plan';
@bind = ($args->{plan});
} else {
$sql = 'SELECT COUNT(*) as count, plan FROM customers GROUP BY plan ORDER BY count DESC';
}
my $rows = $dbh->selectall_arrayref($sql, { Slice => {} }, @bind);
my @lines = map { "$_->{plan}: $_->{count} customers" } @$rows;
return {
content => [{
type => 'text',
text => join("\n", @lines) || "No customers found"
}],
};
}
);
Now bundle these tools into an SDK MCP server and use them in a query:
# Create the MCP server
my $server = create_sdk_mcp_server(
name => 'customerdb',
tools => [$find_customer, $customer_stats],
version => '1.0.0',
);
# Configure the agent to use our tools
my $options = Claude::Agent::Options->new(
mcp_servers => { customerdb => $server },
allowed_tools => $server->tool_names, # ['mcp__customerdb__find_customer', ...]
permission_mode => 'bypassPermissions',
max_turns => 10,
);
# Now Claude can query your database naturally
my $loop = IO::Async::Loop->new;
my $iter = query(
prompt => 'How many customers do we have on each plan? ' .
'Also, look up the customer with email alice@example.com',
options => $options,
loop => $loop,
);
# Stream the response
while (my $msg = $iter->next) {
if ($msg->isa('Claude::Agent::Message::Assistant')) {
for my $block ($msg->content_blocks) {
print $block->text if $block->isa('Claude::Agent::Content::Text');
}
}
elsif ($msg->isa('Claude::Agent::Message::Result')) {
print "\n\nQuery complete.\n";
last;
}
}
When you run this, Claude will intelligently call both tools to answer your question. It might first call customer_stats with no arguments to get the plan breakdown, then call find_customer with email => 'alice@example.com' to look up that specific record. You'll see output like:
Let me check our customer data for you.
We have the following customers by plan:
- pro: 1,247 customers
- free: 3,892 customers
- enterprise: 89 customers
For alice@example.com, I found:
- Name: Alice Chen
- Plan: enterprise
- Member since: 2024-03-15
Behind the scenes, the SDK creates a Unix socket for communication between your main process and a lightweight MCP protocol handler. When Claude calls a tool, the request flows through the socket to your handler, which executes synchronously with full access to $dbh and any other state in scope. The result flows back to Claude, and the conversation continues.
This pattern is incredibly useful for building AI-powered interfaces to your existing systems. You're not building a new API. You're exposing capabilities that your Perl code already has, with Claude handling the natural language understanding and your handlers doing the actual work. The JSON Schema validation ensures Claude passes the right parameters, and your handlers can return structured results or friendly error messages.
A few things to note about handler implementation:
-
Return structure: Always return a hashref with a
contentarray. Each element should havetype => 'text'and atextfield. -
Error handling: Set
is_error => 1in your return value when something goes wrong. Claude will understand the operation failed. - Input validation: The SDK validates inputs against your JSON Schema, but you may want additional business logic validation in your handler.
-
Security: Be thoughtful about what you expose. The
enumconstraint incustomer_statslimits which plans can be queried. You can use similar patterns to restrict what data Claude can access.
The Hook System for Fine-Grained Control
When you're running AI agents in production, you need visibility. What tools is Claude calling? With what parameters? How long did each operation take? Did anything get blocked? The Claude Agent SDK's hook system gives you complete control over the agent's tool execution lifecycle, letting you intercept, inspect, modify, or block any operation.
Think of hooks as middleware for AI agent operations. Every time Claude wants to call a tool, whether it's reading a file, running a bash command, or calling one of your custom MCP tools: your hooks get first dibs. You can log the operation, check it against security policies, modify the parameters, or shut it down entirely. And you get hooks for multiple lifecycle points: before execution, after success, after failure, and more.
The system is built around matchers that bind patterns to callbacks:
use Claude::Agent::Hook::Matcher;
use Claude::Agent::Hook::Result;
my $matcher = Claude::Agent::Hook::Matcher->new(
matcher => 'Bash', # Tool name pattern (regex or exact match)
timeout => 60, # Hook execution timeout in seconds
hooks => [ # Array of callback subroutines
sub {
my ($input, $tool_use_id, $context) = @_;
# Your logic here
return Claude::Agent::Hook::Result->proceed();
},
],
);
Each hook callback receives three arguments: $input (a hashref with tool_name and tool_input), $tool_use_id (a unique identifier for this specific invocation), and $context (a Claude::Agent::Hook::Context object with session metadata like session_id and cwd).
Your hooks return decisions using the Claude::Agent::Hook::Result factory:
# Let the operation proceed unchanged
return Claude::Agent::Hook::Result->proceed();
# Allow but modify the input parameters
return Claude::Agent::Hook::Result->allow(
updated_input => { command => 'sanitized_command' },
reason => 'Modified for security',
);
# Block the operation entirely
return Claude::Agent::Hook::Result->deny(
reason => 'This operation violates security policy',
);
The available hook events cover the tool execution lifecycle:
| Event | When It Fires |
|---|---|
PreToolUse |
Before any tool executes |
PostToolUse |
After a tool completes successfully |
PostToolUseFailure |
After a tool fails |
These three events are the workhorses of the hook system, giving you complete visibility into tool execution. The SDK also defines additional event types (SessionStart, SessionEnd, SubagentStart, SubagentStop, PermissionRequest, Notification, Stop, PreCompact, UserPromptSubmit) that cover session lifecycle, subagent management, and user interactions.
# Security hook, only fires for Bash tool
my $bash_security = Claude::Agent::Hook::Matcher->new(
matcher => 'Bash', # Exact match on tool name
hooks => [sub {
my ($input, $tool_use_id, $context) = @_;
my $command = $input->{tool_input}{command} // '';
# Define blocked patterns
my @dangerous_patterns = (
qr/\brm\s+-rf\s+[\/~]/, # rm,rf against root or home
qr/\bsudo\b/, # No sudo commands
qr/\bchmod\s+777\b/, # World-writable permissions
qr/>\s*\/etc\//, # Redirecting to /etc
qr/\bcurl\b.*\|\s*\bbash\b/, # Piping curl to bash
qr/\beval\b/, # Command eval
);
for my $pattern (@dangerous_patterns) {
if ($command =~ $pattern) {
write_audit_log({
timestamp => scalar(gmtime) . ' UTC',
event => 'TOOL_BLOCKED',
tool_use_id => $tool_use_id,
tool_name => 'Bash',
reason => 'Matched dangerous pattern',
pattern => "$pattern",
severity => 'CRITICAL',
});
return Claude::Agent::Hook::Result->deny(
reason => 'This command has been blocked by security policy.',
);
}
}
return Claude::Agent::Hook::Result->proceed();
}],
);
Hook execution order matters. When you provide multiple matchers for the same event, they run in array order. Within a single matcher, if any hook returns
allowordeny, subsequent hooks in that matcher don't execute. The decision is final.Matcher patterns are flexible. Use an exact string like
'Bash'to match a specific tool, a regex pattern like'mcp__.*'to match all MCP tools, or omit the matcher entirely to catch everything. The SDK includes ReDoS protection to prevent pathological regex patterns from hanging your process.Hooks are exception-safe. If your callback throws, the SDK catches it and returns
{ decision => 'error' }. Your agent keeps running, and you can enableCLAUDE_AGENT_DEBUG=1to see the full stack trace.The context object is your friend. The
$contextparameter gives you the session ID (essential for correlating logs across a conversation), the current working directory, and the tool details. Use this metadata to make intelligent decisions about what to allow.
Subagents for Specialised Tasks
Sometimes a single agent isn't enough. Maybe you need to run multiple analyses in parallel, checking for security vulnerabilities while simultaneously reviewing code style. Maybe you want to isolate a complex task so it doesn't pollute your main conversation context. Or maybe you need specialised expertise: one agent focused purely on security, another on performance, each with tailored instructions and tool access.
This is what subagents are for. The Claude Agent SDK lets you define specialised agent profiles that your main agent can spawn on demand. Each subagent runs in its own isolated context with its own system prompt, tool permissions, and even model selection. Think of them as expert consultants your agent can call in when it needs help.
The architecture is elegant. You define subagents as configuration objects with four properties:
use Claude::Agent::Subagent;
my $subagent = Claude::Agent::Subagent->new(
description => '...', # When should Claude use this agent?
prompt => '...', # System prompt defining expertise
tools => [...], # Allowed tools (optional, inherits if not set)
model => '...', # Model override (optional, 'sonnet', 'opus', 'haiku')
);
The description is key. Claude uses this to decide when to delegate. Write it like you're explaining to a colleague: "Expert security reviewer for vulnerability analysis" tells Claude exactly what this agent does. The prompt is the system prompt that shapes the subagent's behaviour, giving it the specialised knowledge and instructions it needs.
The subagent architecture provides several powerful capabilities:
Context isolation. Each subagent starts fresh with only its system prompt. There is no accumulated context from earlier in the conversation. This prevents context pollution and keeps analyses focused.
Tool restriction. Notice how secrets_detector doesn't have Bash access. It can only read files. This is defense in depth: even if the AI were to malfunction, a secrets-scanning agent physically cannot execute commands.
Model selection. Use Opus for complex security analysis where you need the strongest reasoning. Use Haiku for straightforward pattern-matching tasks. Your main agent can be Sonnet as the orchestrator. This optimises both cost and capability.
Parallel potential. While Claude currently executes subagents sequentially, the architecture supports parallel execution. When you spawn multiple subagents, their isolated contexts mean results can be combined without interference.
Async Tool Handlers
For tools that perform I/O operations—HTTP requests, database queries, file operations—blocking the event loop is wasteful. The SDK supports async tool handlers that return Futures, enabling true non-blocking execution.
Your handler receives the IO::Async::Loop as its second parameter. Use it to perform async operations and return a Future that resolves with your result:
use Future::AsyncAwait;
use Net::Async::HTTP;
my $fetch_url = tool(
'fetch_url',
'Fetch content from a URL asynchronously',
{
type => 'object',
properties => {
url => { type => 'string', description => 'URL to fetch' },
},
required => ['url'],
},
async sub {
my ($args, $loop) = @_;
my $http = Net::Async::HTTP->new;
$loop->add($http);
my $response = await $http->GET($args->{url});
return {
content => [{
type => 'text',
text => sprintf("Status: %d\nBody: %s",
$response->code,
substr($response->decoded_content, 0, 1000)),
}],
};
}
);
The same pattern works for hooks. Your hook callback can return a Future for async validation:
my $async_security_hook = Claude::Agent::Hook::Matcher->new(
matcher => '.*',
hooks => [
async sub {
my ($input, $tool_use_id, $context, $loop) = @_;
# Async check against a security policy service
my $http = Net::Async::HTTP->new;
$loop->add($http);
my $resp = await $http->POST(
'https://security.internal/check',
content => encode_json($input),
);
if ($resp->code == 403) {
return Claude::Agent::Hook::Result->deny(
reason => 'Blocked by security policy',
);
}
return Claude::Agent::Hook::Result->proceed();
},
],
);
One powerful pattern enabled by the shared event loop: spawning nested queries from within a tool handler. Your tool can invoke Claude as a sub-agent:
my $research_tool = tool(
'deep_research',
'Spawn a sub-agent to research a topic',
{
type => 'object',
properties => {
topic => { type => 'string' },
},
required => ['topic'],
},
sub {
my ($args, $loop) = @_;
# Spawn a sub-query using the shared event loop
my $sub_query = query(
prompt => "Research thoroughly: $args->{topic}",
options => Claude::Agent::Options->new(
allowed_tools => ['Read', 'Glob', 'WebSearch'],
permission_mode => 'bypassPermissions',
max_turns => 5,
),
loop => $loop,
);
my $result = '';
while (my $msg = $sub_query->next) {
if ($msg->isa('Claude::Agent::Message::Result')) {
$result = $msg->result // '';
last;
}
}
return {
content => [{ type => 'text', text => $result }],
};
}
);
Sync handlers continue to work unchanged. The SDK automatically wraps synchronous return values in Futures, so you can mix sync and async tools freely.
Wrapping Up
The Claude Agent SDK for Perl brings agentic AI capabilities directly into your existing infrastructure. From custom MCP tools that access your application state, to a flexible hook system for security and observability, to specialised subagents for parallel expertise—the toolkit is designed for real-world automation. Whether you're building intelligent code review pipelines, DevOps automation, or AI-powered interfaces to legacy systems, the SDK provides the primitives you need while keeping you in control. The code is available on CPAN, and I look forward to seeing what you build with it.
https://metacpan.org/pod/Claude::Agent
Here are some extensions I've built already using the SDK:
https://metacpan.org/pod/Claude::Agent::Code::Review
https://metacpan.org/pod/Claude::Agent::Code::Refactor
https://metacpan.org/pod/Wordsmith::Claude
https://metacpan.org/dist/Acme-Claude-Shell/view/bin/acme_claude_shell
-
App::Greple - extensible grep with lexical expression and region handling
- Version: 10.02 on 2026-01-09, with 56 votes
- Previous CPAN version: 10.01 was 9 days before
- Author: UTASHIRO
-
App::Netdisco - An open source web-based network management tool.
- Version: 2.097002 on 2026-01-09, with 818 votes
- Previous CPAN version: 2.097001
- Author: OLIVER
-
App::Sqitch - Sensible database change management
- Version: v1.6.1 on 2026-01-06, with 3087 votes
- Previous CPAN version: v1.6.0 was 3 months before
- Author: DWHEELER
-
CPANSA::DB - the CPAN Security Advisory data as a Perl data structure, mostly for CPAN::Audit
- Version: 20260104.001 on 2026-01-04, with 25 votes
- Previous CPAN version: 20251228.001 was 6 days before
- Author: BRIANDFOY
-
DateTime::Format::Natural - Parse informal natural language date/time strings
- Version: 1.23 on 2026-01-04, with 19 votes
- Previous CPAN version: 1.23 was 5 days before
- Author: SCHUBIGER
-
Firefox::Marionette - Automate the Firefox browser with the Marionette protocol
- Version: 1.69 on 2026-01-10, with 19 votes
- Previous CPAN version: 1.68 was 3 months, 26 days before
- Author: DDICK
-
GD - Perl interface to the libgd graphics library
- Version: 2.84 on 2026-01-04, with 32 votes
- Previous CPAN version: 2.83 was 1 year, 6 months, 11 days before
- Author: RURBAN
-
IO::Socket::SSL - Nearly transparent SSL encapsulation for IO::Socket::INET.
- Version: 2.098 on 2026-01-06, with 49 votes
- Previous CPAN version: 2.097
- Author: SULLR
-
JSON::Schema::Modern - Validate data against a schema using a JSON Schema
- Version: 0.632 on 2026-01-06, with 16 votes
- Previous CPAN version: 0.631 was 12 days before
- Author: ETHER
-
MetaCPAN::Client - A comprehensive, DWIM-featured client to the MetaCPAN API
- Version: 2.037000 on 2026-01-07, with 27 votes
- Previous CPAN version: 2.036000
- Author: MICKEY
-
MIME::Lite - low-calorie MIME generator
- Version: 3.035 on 2026-01-08, with 35 votes
- Previous CPAN version: 3.034 was 2 days before
- Author: RJBS
-
Module::Starter - a simple starter kit for any module
- Version: 1.81 on 2026-01-09, with 34 votes
- Previous CPAN version: 1.80
- Author: XSAWYERX
-
Perl::Tidy - indent and reformat perl scripts
- Version: 20260109 on 2026-01-08, with 147 votes
- Previous CPAN version: 20250912 was 3 months, 26 days before
- Author: SHANCOCK
-
perlsecret - Perl secret operators and constants
- Version: 1.018 on 2026-01-09, with 55 votes
- Previous CPAN version: 1.017 was 4 years, 2 months before
- Author: BOOK
-
Type::Tiny - tiny, yet Moo(se)-compatible type constraint
- Version: 2.010001 on 2026-01-06, with 148 votes
- Previous CPAN version: 2.010000 was 7 days before
- Author: TOBYINK
-
UV - Perl interface to libuv
- Version: 2.001 on 2026-01-06, with 14 votes
- Previous CPAN version: 2.000 was 4 years, 5 months, 8 days before
- Author: PEVANS
I just saw perlmodules.net is down for 1-2 weeks mentioning an upcoming outage because of changes to the MetaCPAN API.
Because metacpan.org changes its API in a major way, and I need to change this site accesses it.
I see that there's the unmerged pull request metacpan/metacpan-api#1109, but I didn't see anything in the MetaCPAN::Client repo.
[link] [comments]
Because metacpan.org changes its API in a major way, and I need to change this site accesses it.
Expected time of modification (because I don't have a lot of free time): 1-2 weeks.
Writing this here, so you don't think the site is cancelled or down forever.
Get it, as usual, from my Wiki Haven.
I have not yet generated a new JSTree version but I have started cleaning up the code
in CPAN::MetaCurator...
https://codeberg.org/ggxx/nfo - This is a small Perl script that manages a small Emacs configuration dedicated to reading info documents. It's meant to be an alternative to the stand-alone info program.
The Problem(s)
- The stand-alone info program repulses people.
- The navigation is unintuitive and the keybindings are unfamiliar (if you're not an Emacs user).
- A lot of good documentation goes unread as a result.
A Solution
- Emacs is the best info reader.
- By using https://codeberg.org/ggxx/info-nav, navigation can be completely mouse-driven, and you don't even have to know any keybindings.
- Setting up Emacs for a non-Emacs user can be a daunting task, so do it for them automagically. (The config is isolated in `~/.config/nfo/` and doesn't affect your main Emacs config if you have one.)
- Make it easy to use for non-Emacs people by providing a CLI utility, `nfo`.
[link] [comments]
sorry for yet another stupid questions
I have config file containing regexps like
/abc/
/bcd/i
I want to convert each line to Perl regex and then apply whole list to some string. How I can do this?
[link] [comments]
In a script I'm using constants (use constant ...) to allow re-use ion actual regular expressions, using the pattern from https://stackoverflow.com/a/69379743/6607497.
However when using a {...} repeat specifier following such constant expansion, Perl wants to tread the constant as a hash variable.
The question is how to avoid that.
Code example:
main::(-e:1): 1
DB<1> use constant CHARSET => '[[:graph:]]'
DB<2> x "foo" =~ qr/^[[:graph:]]{3,}$/
0 1
DB<3> x "foo" =~ qr/^${\CHARSET}{3,}$/
Not a HASH reference at (eval 8)[/usr/lib/perl5/5.26.1/perl5db.pl:738] line 2.
DB<4> x "foo" =~ qr/^${\CHARSET}\{3,}$/
empty array
DB<5> x $^V
0 v5.26.1
According to https://stackoverflow.com/a/79845011/6607497 a solution may be to add a space that's being ignored, like this: qr/^${\CHARSET} {3,}$/x; however I don't understand why this works, because outside of a regular expression the space before { is being ignored:
DB<6> x "foo" =~ qr/^${\CHARSET} {3,}$/x
0 1
DB<7> %h = (a => 3)
DB<8> x $h{a}
0 3
DB<9> x $h {a}
0 3
The manual page (perlop(1) on "Quote and Quote-like Operators") isn't very precise on that:
For constructs that do interpolate, variables beginning with "$" or "@" are interpolated. Subscripted variables such as $a[3] or "$href->{key}[0]" are also interpolated, as are array and hash slices. But method calls such as "$obj->meth" are not.
I'd like to use constants to build regular expressions. However, in this case I got an unexpected syntax error:
#!/usr/bin/perl
use strict;
use warnings;
use constant CR_SAFE => '[:alnum:]@,._\-!%=';
# quote argument if needed
sub cond_quote($)
{
my $arg = shift;
return $arg
if ($arg =~ /^[${\CR_SAFE}]+$/);
$arg =~ s/[^${\CR_SAFE}[:space:]]/\\$&/g;
return '"' . $arg . '"';
}
$ perl -c ./foo.pl
syntax error at ./foo.pl line 14, near "[:"
./foo.pl had compilation errors.
However if I move [:space:] before expanding the constant ($arg =~ s/[^[:space:]${\CR_SAFE}]/\\$&/g;), then I get no syntax error.
Perl version is 5.26.1 on x86_64.
Don't I see the obvious, or who can explain?

DBIx::Class::Async module just leveled up. Thanks to sharp-eyed users who spotted what I missed — sometimes the best features come from the community, not the creator. Please follow the link for more information: https://theweeklychallenge.org/blog/dbix-class-async-update
After skipping a week again due to circumstances, all three of us attended this shorter meeting, which Paul had to leave early.
We went over a number of pending administrative requests, including possible additions to the core team and the process to follow for this.
We reviewed issue #24013 about the fallout of fatalizing calls to undefined import/unimport methods with arguments. We decided that this deprecation be rescinded and the PR be reverted. This mistake should only warn, with a category to make it easily usably fatalizable (so that whoever wants the error can opt into it).
Weekly Challenge 355
Each week Mohammad S. Anwar sends out The Weekly Challenge, a chance for all of us to come up with solutions to two weekly tasks. My solutions are written in Python first, and then converted to Perl. It's a great way for us all to practice some coding.
Task 1: Thousand Separator
Task
You are given a positive integer, $int.
Write a script to add thousand separator, , and return as string
My solution
Both Perl and Python have modules do this. There is absolutely no need to reinvent a perfectly round wheel. As int is a reserved word in Python, I use the variable number instead. The Python solution is as follows.
def thousand_separator(number: int) -> str:
return f"{number:,}"
The Perl solution uses the Number::Format module.
use Number::Format 'format_number';
sub main ($int) {
say format_number($int);
}
It should of course be noted that not all countries use the comma to separate grouping of digits. Some countries use the dot character instead. India also groups numbers by the hundred after the first thousand (e.g. 12,45,67,890).
Examples
$ ./ch-1.py 123
123
$ ./ch-1.py 1234
1,234
$ ./ch-1.py 1000000
1,000,000
$ ./ch-1.py 1
1
$ ./ch-1.py 12345
12,345
Task 2: Mountain Array
Task
You are given an array of integers, @ints.
Write a script to return true if the given array is a valid mountain array.
An array is mountain if and only if:
-
arr.length >= 3, and - There exists some
iwith0 < i < arr.length - 1such that:arr[0] < arr[1] < ... < arr[i - 1] < arr[i]arr[i] > arr[i + 1] > ... > arr[arr.length - 1]
My solution
This turned out to be harder than I thought it would be. I'm not sure if this is the best solution. I advantage of this solution is I loop over the list once.
These are the steps I take.
- Check that there are at least three items in the
intslist (array in Perl). ReturnFalseif there are not. - Set the variable
last_intto the first item in theintslist, and the variabledirectiontoup. - Check that the second item is higher than the first, and return
Falseif it is not. This ensures that descents won't return the wrong result. - Loop through the remaining items in
intssetting the value tocurrent_int.- If
current_intandlast_intare the same, returnFalse. - If
directionisupand thecurrent_intvalue is less thanlast_int, setdirectiontodown. - If
directionisdownand thecurrent_intvalue is higher thanlast_int, returnFalse. - Set
last_intto thecurrent_intvalue.
- If
- If
directionis stillup, returnFalse. This ensures ascents won't return the wrong result. - Return
True.
def mountain_array(ints: list) -> bool:
if len(ints) < 3:
return False
direction = 'up'
last_int = ints[0]
if ints[1] <= last_int:
return False
for current_int in ints[1:]:
if current_int == last_int:
return False
if direction == 'up':
if current_int < last_int:
direction = 'down'
else:
if current_int > last_int:
return False
last_int = current_int
if direction == 'up':
return False
return True
The Perl solution follows the same logic.
Examples
$ ./ch-2.py 1 2 3 4 5
False
$ ./ch-2.py 0 2 4 6 4 2 0
True
$ ./ch-2.py 5 4 3 2 1
False
$ ./ch-2.py 1 3 5 5 4 2
False
$ ./ch-2.py 1 3 2
True
$ ./ch-2.py 1 3
False
foobar is a Perl script that prints to both standard output and standard error. In a separate Perl script echo-stderr, I run foobar and capture its standard error using IPC::Open3's open3 function, and simply echo it back.
Here's the code for echo-stderr:
#!/usr/bin/perl -w
use IPC::Open3;
use Symbol 'gensym';
$fh = gensym;
$pid = open3('STDIN', 'STDOUT', $fh, './foobar') or die "$0: failed to run ./foobar\n";
while ( <$fh> ) {
print STDERR $_;
}
close $fh;
waitpid($pid, 0);
The result is that whatever foobar writes to standard error is printed, nothing that it writes to standard output is.
And there is an error at the end:
<message written to STDERR>
<message written to STDERR>
...
Unable to flush stdout: Bad file descriptor
What is the reason for this error?
Originally published at Perl Weekly 754
Hi there,
Happy New Year, everyone (albeit a belated one).
Any New Year's resolutions this time? For me, none. I'm too old for such customs. However, I always have a full plate, which is nice.
Normally, you expect the new year to bring new energy but that rarely happens. Instead you often already feel tired from a busy schedule, gift shopping, and attending parties. How was yours?
I luckily had an extended break this time, so I used it to work on my upcoming book on DBIx::Class. I have a habit of getting deep into the skin of a subject, and this time was no different. When I shared updates on social media, I received many positive reviews. One suggestion to add asynchronous operation support to DBIx::Class. I must admit that before this book, I had barely touched it, although my current work place uses it extensively. In the past, I mostly worked with an in-house ORM written in Perl.
While working on the book, I decided to share my initial draft for supporting async operations in DBIx::Class and released DBIx::Class::Async on the first day of 2026. It's still experimental, but I received a few suggestions from big shots, which led to another quick update to address their feedback. I wrote a blog post to introduce the new creation and am working on another post to discuss further improvements since the initial release. I had hoped to have it ready for this newsletter, but not everything goes according to plan. Never mind - in a couple of days - I will share it with you.
Two major blockers I encountered during my research were transactions and searches with prefetch. I will continue working on these until I find a satisfactory solution. I also come across another new distribution on the same subject, DBIx::Quick. I'm happy to see the sudden renewed interest in ORM in general.
Today is my first working day of 2026, so I'm very excited. Please share your experiences.
Enjoy rest of the newsletter.
--
Your editor: Mohammad Sajid Anwar.
Announcements
The Underbar, episode 8
As preparations are underway for the Perl Toolchain Summit 2026, this short episode is the last excerpt recorded during PTS 2025 (as a tangent during the CPAN Testers interview, published as episode 6). BooK starts by explaining the selection process for the Perl Toolchain Summit and some of the history, and then Doug, Ruth, Breno and Ferki reminisce about what makes the event so special.
Articles
App::HTTPThis: the tiny web server I keep reaching for
Dave shares his long-time favorite tool for static site work: App::HTTPThis. This Perl module is a friction-free command-line web server that instantly serves any directory over HTTP, perfect for testing links, assets, and real browser behavior without the overhead of Apache or nginx.
DBIx::Class::Async
A gentle tntroduction to DBIx::Class::Async. Please share your suggestions.
Episode 9: Perl, the Language That Refuses to Die (And Honestly, Good for It)
A very interesting read, unfortunately it is behind Medium's paywall, marked as members only.
jq-lite: A jq Alternative for Legacy and Restricted Systems
jq-lite is a jq-compatible JSON processor written in pure Perl. It is designed for legacy, restricted, and minimal environments where installing jq is not possible.
The Weekly Challenge
The Weekly Challenge by Mohammad Sajid Anwar will help you step out of your comfort-zone. You can even win prize money of $50 by participating in the weekly challenge. We pick one champion at the end of the month from among all of the contributors during the month, thanks to the sponsor Lance Wicks.
The Weekly Challenge - 355
Welcome to a new week with a couple of fun tasks "Thousand Separator" and "Mountain Array". If you are new to the weekly challenge then why not join us and have fun every week. For more information, please read the FAQ.
RECAP - The Weekly Challenge - 354
Enjoy a quick recap of last week's contributions by Team PWC dealing with the "Min Abs Diff" and "Shift Grid" tasks in Perl and Raku. You will find plenty of solutions to keep you busy.
MAD Shift
The post showcases excellent problem-solving skills and clear pedagogical explanations. The implemented code for the first challenge and the conceptual solution for the second are both technically sound and well-justified.
Shifted Differences
This is a competent Perl implementation that demonstrates strong algorithmic thinking, particularly for the grid rotation problem. Jorg shows good understanding of Perl's array manipulation capabilities and mathematical optimisation.
Perl Weekly Challenge 354
Expert-level PDL implementation that leverages specialized numerical libraries for concise, high-performance solutions. This approach is optimal for numerical computing contexts but requires deep PDL knowledge.
Min Abs Diff Shift Grid. What??
Matthias demonstrates both theoretical knowledge (complexity analysis) and practical skill (performance optimisation), making this an exemplary technical solution.
Some Grids
This is a well-written and highly educational post. Packy successfully demonstrates how to solve algorithmic problems using the distinct styles of different programming languages. The solutions are correct, clearly explained, and follow good practices.
Mad numbers and shifty grid
Peter demonstrates a pragmatic, straightforward approach to solving the weekly challenge #354. The code is clear, functional, and focuses on delivering correct results with minimal complexity.
The Weekly Challenge #354
This solution set takes a functional but brute-force approach to the problems. While correct, it prioritizes straightforward implementation over algorithmic optimisation.
Min Grid Diffs the Shift
This is a sophisticated, well-optimized solution that correctly identifies and implements the most efficient algorithms for both challenges. Roger demonstrates strong understanding of algorithmic complexity and language-specific optimizations.
New Year, New Challenges
High-quality, production-ready solutions with excellent algorithm choices, clean implementations, and thoughtful language-specific optimizations. Simon demonstrates strong command of both algorithmic thinking and practical coding.
Rakudo
2025.52 Release #188 & State of the Onion
Weekly collections
NICEPERL's lists
Great CPAN modules released last week.
The corner of Gabor
A couple of entries sneaked in by Gabor.
The new pull-request challenge (club)
Recently I saw that the PullRequest.Club closed on January 1st. It's a pity as it was a good idea. But don't worry, the new PRC is here. Less organized but here. The challenge is to send a PR to any CPAN module once a week and share the link to it on this issue. (see my first comments on that issue). If you have never contributed to an open source project or if you have never sent a PR, don't worry. I organize live online sessions where I demonstrate the whole process as I am working on a project. You can watch. You can ask question. You can learn. Then you can implement and send your own PRs. Check the events here!
Events
Perl Maven online: Live Open Source contribution
January 08, 2025
Boston.pm - online
January 13, 2025
Perl Maven online: Live Open Source contribution
January 24, 2025
German Perl/Raku Workshop 2026 in Berlin
March 16-18, 2025
You joined the Perl Weekly to get weekly e-mails about the Perl programming language and related topics.
Want to see more? See the archives of all the issues.
Not yet subscribed to the newsletter? Join us free of charge!
(C) Copyright Gabor Szabo
The articles are copyright the respective authors.
Version Control SVN
Whenever I’m building a static website, I almost never start by reaching for Apache, nginx, Docker, or anything that feels like “proper infrastructure”. Nine times out of ten I just want a directory served over HTTP so I can click around, test routes, check assets, and see what happens in a real browser.
For that job, I’ve been using App::HTTPThis for years.
It’s a simple local web server you run from the command line. Point it at a directory, and it serves it. That’s it. No vhosts. No config bureaucracy. No “why is this module not enabled”. Just: run a command and you’ve got a website.
Why I’ve used it for years
Static sites are deceptively simple… right up until they aren’t.
-
You want to check that relative links behave the way you think they do.
-
You want to confirm your CSS and images are loading with the paths you expect.
-
You want to reproduce “real HTTP” behaviour (caching headers, MIME types, directory handling) rather than viewing files directly from disk.
Sure, you can open file:///.../index.html in a browser, but that’s not the same thing as serving it over HTTP. And setting up Apache (or friends) feels like bringing a cement mixer to butter some toast.
With http_this, the workflow is basically:
-
cdinto your site directory -
run a single command
-
open a URL
-
get on with your life
It’s the “tiny screwdriver” that’s always on my desk.
Why I took it over
A couple of years ago, the original maintainer had (entirely reasonably!) become too busy elsewhere and the distribution wasn’t getting attention. That happens. Open source is like that.
But I was using App::HTTPThis regularly, and I had one small-but-annoying itch: when you visited a directory URL, it would always show a directory listing – even if that directory contained an index.html. So instead of behaving like a typical web server (serve index.html by default), it treated index.html as just another file you had to click.
That’s exactly the sort of thing you notice when you’re using a tool every day, and it was irritating enough that I volunteered to take over maintenance.
(If you want to read more on this story, I wrote a couple of blog posts.)
What I’ve done since taking it over
Most of the changes are about making the “serve a directory” experience smoother, without turning it into a kitchen-sink web server.
1) Serve index pages by default (autoindex)
The first change was to make directory URLs behave like you’d expect: if index.html exists, serve it automatically. If it doesn’t, you still get a directory listing.
2) Prettier index pages
Once autoindex was in place, I then turned my attention to the fallback directory listing page. If there isn’t an index.html, you still need a useful listing — but it doesn’t have to look like it fell out of 1998. So I cleaned up the listing output and made it a bit nicer to read when you do end up browsing raw directories.
3) A config file
Once you’ve used a tool for a while, you start to realise you run it the same way most of the time.
A config file lets you keep your common preferences in one place instead of re-typing options. It keeps the “one command” feel, but gives you repeatability when you want it.
4) --host option
The ability to control the host binding sounds like an edge case until it isn’t.
Sometimes you want:
-
only
localhostaccess for safety; -
access from other devices on your network (phone/tablet testing);
-
behaviour that matches a particular environment.
A --host option gives you that control without adding complexity to the default case.
The Bonjour feature (and what it’s for)
This is the part I only really appreciated recently: App::HTTPThis can advertise itself on your local network using mDNS / DNS-SD – commonly called Bonjour on Apple platforms, Avahi on Linux, and various other names depending on who you’re talking to.
It’s switched on with the --name option.
When you do that, http_this publishes an _http._tcp service on your local network with the instance name you chose (MyService in this case). Any device on the same network that understands mDNS/DNS-SD can then discover it and resolve it to an address and port, without you having to tell anyone, “go to http://192.168.1.23:7007/”.
Confession time: I ignored this feature for ages because I’d mentally filed it under “Apple-only magic” (Bonjour! very shiny! probably proprietary!). It turns out it’s not Apple-only at all; it’s a set of standard networking technologies that are supported on pretty much everything, just under a frankly ridiculous number of different names. So: not Apple magic, just local-network service discovery with a branding problem.
Because I’d never really used it, I finally sat down and tested it properly after someone emailed me about it last week, and it worked nicely, nicely enough that I’ve now added a BONJOUR.md file to the repo with a practical explanation of what’s going on, how to enable it, and a few ways to browse/discover the advertised service.
(If you’re curious, look for _http._tcp and your chosen service name.)
It’s a neat quality-of-life feature if you’re doing cross-device testing or helping someone else on the same network reach what you’re running.
Related tools in the same family
App::HTTPThis is part of a little ecosystem of “run a thing here quickly” command-line apps. If you like the shape of http_this, you might also want to look at these siblings:
-
https_this : like
http_this, but served over HTTPS (useful when you need to test secure contexts, service workers, APIs that require HTTPS, etc.) -
cgi_this : for quick CGI-style testing without setting up a full web server stack
-
dav_this : serves content over WebDAV (handy for testing clients or workflows that expect DAV)
-
ftp_this : quick FTP server for those rare-but-real moments when you need one
They all share the same basic philosophy: remove the friction between “I have a directory” and “I want to interact with it like a service”.
Wrapping up
I like tools that do one job, do it well, and get out of the way. App::HTTPThis has been that tool for me for years and it’s been fun (and useful) to nudge it forward as a maintainer.
If you’re doing any kind of static site work — docs sites, little prototypes, generated output, local previews — it’s worth keeping in your toolbox.
And if you’ve got ideas, bug reports, or platform notes (especially around Bonjour/Avahi weirdness), I’m always happy to hear them.
The post App::HTTPThis: the tiny web server I keep reaching for first appeared on Perl Hacks.
Whenever I’m building a static website, I almost never start by reaching for Apache, nginx, Docker, or anything that feels like “proper infrastructure”. Nine times out of ten I just want a directory served over HTTP so I can click around, test routes, check assets, and see what happens in a real browser.
For that job, I’ve been using App::HTTPThis for years.
It’s a simple local web server you run from the command line. Point it at a directory, and it serves it. That’s it. No vhosts. No config bureaucracy. No “why is this module not enabled”. Just: run a command and you’ve got a website.
Why I’ve used it for years
Static sites are deceptively simple… right up until they aren’t.
You want to check that relative links behave the way you think they do.
You want to confirm your CSS and images are loading with the paths you expect.
You want to reproduce “real HTTP” behaviour (caching headers, MIME types, directory handling) rather than viewing files directly from disk.
Sure, you can open file:///.../index.html in a browser, but that’s not the same thing as serving it over HTTP. And setting up Apache (or friends) feels like bringing a cement mixer to butter some toast.
With http_this, the workflow is basically:
cdinto your site directoryrun a single command
open a URL
get on with your life
It’s the “tiny screwdriver” that’s always on my desk.
Why I took it over
A couple of years ago, the original maintainer had (entirely reasonably!) become too busy elsewhere and the distribution wasn’t getting attention. That happens. Open source is like that.
But I was using App::HTTPThis regularly, and I had one small-but-annoying itch: when you visited a directory URL, it would always show a directory listing - even if that directory contained an index.html. So instead of behaving like a typical web server (serve index.html by default), it treated index.html as just another file you had to click.
That’s exactly the sort of thing you notice when you’re using a tool every day, and it was irritating enough that I volunteered to take over maintenance.
(If you want to read more on this story, I wrote a couple of blog posts.)
What I’ve done since taking it over
Most of the changes are about making the “serve a directory” experience smoother, without turning it into a kitchen-sink web server.
1) Serve index pages by default (autoindex)
The first change was to make directory URLs behave like you’d expect: if index.html exists, serve it automatically. If it doesn’t, you still get a directory listing.
2) Prettier index pages
Once autoindex was in place, I then turned my attention to the fallback directory listing page. If there isn’t an index.html, you still need a useful listing — but it doesn’t have to look like it fell out of 1998. So I cleaned up the listing output and made it a bit nicer to read when you do end up browsing raw directories.
3) A config file
Once you’ve used a tool for a while, you start to realise you run it the same way most of the time.
A config file lets you keep your common preferences in one place instead of re-typing options. It keeps the “one command” feel, but gives you repeatability when you want it.
4) --host option
The ability to control the host binding sounds like an edge case until it isn’t.
Sometimes you want:
only
localhostaccess for safety;access from other devices on your network (phone/tablet testing);
behaviour that matches a particular environment.
A --host option gives you that control without adding complexity to the default case.
The Bonjour feature (and what it’s for)
This is the part I only really appreciated recently: App::HTTPThis can advertise itself on your local network using mDNS / DNS-SD – commonly called Bonjour on Apple platforms, Avahi on Linux, and various other names depending on who you’re talking to.
It’s switched on with the --name option.
http_this --name MyService
When you do that, http_this publishes an _http._tcp service on your local network with the instance name you chose (MyService in this case). Any device on the same network that understands mDNS/DNS-SD can then discover it and resolve it to an address and port, without you having to tell anyone, “go to http://192.168.1.23:7007/”.
Confession time: I ignored this feature for ages because I’d mentally filed it under “Apple-only magic” (Bonjour! very shiny! probably proprietary!). It turns out it’s not Apple-only at all; it’s a set of standard networking technologies that are supported on pretty much everything, just under a frankly ridiculous number of different names. So: not Apple magic , just local-network service discovery with a branding problem.
Because I’d never really used it, I finally sat down and tested it properly after someone emailed me about it last week, and it worked nicely, nicely enough that I’ve now added a BONJOUR.md file to the repo with a practical explanation of what’s going on, how to enable it, and a few ways to browse/discover the advertised service.
(If you’re curious, look for _http._tcp and your chosen service name.)
It’s a neat quality-of-life feature if you’re doing cross-device testing or helping someone else on the same network reach what you’re running.
Related tools in the same family
App::HTTPThis is part of a little ecosystem of “run a thing here quickly” command-line apps. If you like the shape of http_this, you might also want to look at these siblings:
https_this : like
http_this, but served over HTTPS (useful when you need to test secure contexts, service workers, APIs that require HTTPS, etc.)cgi_this : for quick CGI-style testing without setting up a full web server stack
dav_this : serves content over WebDAV (handy for testing clients or workflows that expect DAV)
ftp_this : quick FTP server for those rare-but-real moments when you need one
They all share the same basic philosophy: remove the friction between “I have a directory” and “I want to interact with it like a service”.
Wrapping up
I like tools that do one job, do it well, and get out of the way. App::HTTPThis has been that tool for me for years and it’s been fun (and useful) to nudge it forward as a maintainer.
If you’re doing any kind of static site work — docs sites, little prototypes, generated output, local previews — it’s worth keeping in your toolbox.
And if you’ve got ideas, bug reports, or platform notes (especially around Bonjour/Avahi weirdness), I’m always happy to hear them.
The post App::HTTPThis: the tiny web server I keep reaching for first appeared on Perl Hacks.
A jq-compatible JSON processor written in pure Perl, designed for environments where jq cannot be installed.
-
App::cpm - a fast CPAN module installer
- Version: 0.998003 on 2025-12-29, with 177 votes
- Previous CPAN version: 0.998002 was 24 days before
- Author: SKAJI
-
App::Greple - extensible grep with lexical expression and region handling
- Version: 10.01 on 2025-12-31, with 56 votes
- Previous CPAN version: 10.00 was 19 days before
- Author: UTASHIRO
-
App::Music::ChordPro - A lyrics and chords formatting program
- Version: v6.090.1 on 2026-01-03, with 432 votes
- Previous CPAN version: v6.090.0 was 2 months, 3 days before
- Author: JV
-
CPANSA::DB - the CPAN Security Advisory data as a Perl data structure, mostly for CPAN::Audit
- Version: 20251228.001 on 2025-12-29, with 25 votes
- Previous CPAN version: 20251221.001 was 7 days before
- Author: BRIANDFOY
-
DBD::SQLite - Self Contained SQLite RDBMS in a DBI Driver
- Version: 1.78 on 2026-01-02, with 107 votes
- Previous CPAN version: 1.76 was 1 year, 2 months, 14 days before
- Author: ISHIGAKI
-
Module::Starter - a simple starter kit for any module
- Version: 1.79 on 2026-01-03, with 33 votes
- Previous CPAN version: 1.78 was 7 months, 30 days before
- Author: XSAWYERX
-
Type::Tiny - tiny, yet Moo(se)-compatible type constraint
- Version: 2.010000 on 2025-12-30, with 148 votes
- Previous CPAN version: 2.009_003 was 7 days before
- Author: TOBYINK
-
WebService::Dropbox - Perl interface to Dropbox API
- Version: 2.10 on 2025-12-29, with 12 votes
- Previous CPAN version: 2.09 was 4 years, 6 months, 14 days before
- Author: ASKADNA
In an attempt to avoid switch /x for a complex regular expression, I tried to replace qr/string/ with the following expression:
(map { qr/$_/ } ("a more" . "complex regex"))[0]
As the latter expression uses double quoted strings, I thought I would have to duplicate any backslash that should go into the qr operator. Because of that, I tried something like this:
(map { qr/$_/ } (
"^Load key \"\\Q$host_CA\\E\": "
. 'incorrect passphrase supplied '
. "to decrypt private key$CRLF"
))
However, Perl 5.26 complains with the following error message:
Unrecognized escape \Q passed through in regex; marked by <-- HERE in m/^Load key "\Q <-- HERE ...
It seems I don't have to duplicate the backslash, but I don't understand why. The following examples illustrate the behavior I am seeing:
DB<3> $x='f*o'
DB<4> x qr/\Q$x\E/
0 (?^u:f\\*o)
-> qr/(?^u:f\*o)/
DB<5> x map { qr/$_/ } ("\\Q$x\\E/")
0 (?^u:\\Qf*o\\E/)
-> qr/(?^u:\Qf*o\E\/)/
DB<6> x map { qr/$_/ } ("\Q$x\E/")
0 (?^u:f\\*o/)
-> qr/(?^u:f\*o\/)/
-
App::DBBrowser - Browse SQLite/MySQL/PostgreSQL databases and their tables interactively.
- Version: 2.438 on 2025-12-25, with 18 votes
- Previous CPAN version: 2.437_05 was 7 days before
- Author: KUERBIS
-
Convert::Pheno - A module to interconvert common data models for phenotypic data
- Version: 0.29 on 2025-12-23, with 15 votes
- Previous CPAN version: 0.28 was 8 months, 4 days before
- Author: MRUEDA
-
Devel::MAT - Perl Memory Analysis Tool
- Version: 0.54 on 2025-12-26, with 30 votes
- Previous CPAN version: 0.53 was 1 year, 9 months, 19 days before
- Author: PEVANS
-
Finance::Quote - Get stock and mutual fund quotes from various exchanges
- Version: 1.68 on 2025-12-21, with 145 votes
- Previous CPAN version: 1.68 was 3 days before
- Author: BPSCHUCK
-
HTTP::Tiny - A small, simple, correct HTTP/1.1 client
- Version: 0.092 on 2025-12-27, with 115 votes
- Previous CPAN version: 0.091 was 14 days before
- Author: HAARG
-
JSON::Schema::Modern - Validate data against a schema using a JSON Schema
- Version: 0.631 on 2025-12-25, with 16 votes
- Previous CPAN version: 0.630 was 10 days before
- Author: ETHER
I am developing a Virtualmin plugin. But the problem is to have a link appear under the "Manage Virtual Server" category in the Virtualmin sidebar whenever the feature is enabled for a virtual server (domain).
Despite following the standard plugin structure, the menu item refuses to appear in the Virtualmin UI, although the module is accessible if I manually type the URL or find it in the Webmin "Tools" section (when not hidden).
Environment
- OS: Ubuntu 22.04 / 24.04
- Virtualmin version: Latest
- Webmin version: Latest
File Structure
/usr/share/webmin/my-plugin-folder/
index.cgimodule.infovirtual_feature.pl- ...
Relevant Code
virtual_feature.pl
require 'my-plugin-lib.pl';
sub feature_name {
return "plugin_name";
}
sub feature_label {
return "Plugin Name";
}
sub feature_disables {
return 1;
}
sub feature_check {
return undef;
}
sub feature_setup {
my ($d) = @_;
return undef;
}
sub feature_links {
my ($d) = @_;
# This is intended to place the link under "Manage Virtual Server"
return ({ 'mod' => $module_name,
'desc' => "Plugin Name",
'page' => "index.cgi?dom=" . $d->{'id'},
'cat' => 'server' });
}
1;
module.info
desc=Plugin Name Tool
os_support=*-linux
version=1.6
category=server
depends=virtual-server
virtualmin=1
hidden=1
Expected Behavior
After enabling the feature globally in System Settings -> Features and Plugins, a link should appear in the left-hand sidebar under the "Manage Virtual Server" category.
Actual Behavior
The feature shows up in the "Enabled features" list and can be toggled/saved successfully. However, the link never appears in the sidebar. No errors are logged in /var/webmin/miniserv.error.
What I have tried
- Restarting Webmin (
/etc/webmin/restart). - Hardcoding the module folder name in the
'mod'field offeature_links. - Changing the
'cat'field to'services'or'logs'. - Refreshing the Webmin module cache.
- Verifying that the feature is indeed marked as enabled in the domain's configuration file in
/etc/webmin/virtual-server/domains/.
Is there a specific registration step or a required function in virtual_feature.pl that I am missing for the sidebar injection to work correctly in recent versions of the Virtualmin Authentic Theme?
At an online event through the Perl Maven group we tried to understand this module and even to contriute to it. For more details about the contributions check out the OSDC Perl page.
This example is based on the one in the documentation of the JSON::Schema::Validate and tweaked a bit. It will be useful again if we continue dealing with this module.
examples/json_schema_validate.pl
use JSON::Schema::Validate;
use JSON ();
use open qw( :std :encoding(UTF-8) );
my $schema = {
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'$id' => 'https://example.org/s/root.json',
type => 'object',
required => [ 'name' ],
properties => {
name => { type => 'string', minLength => 5 },
next => { '$dynamicRef' => '#Node' },
},
'$dynamicAnchor' => 'Node',
additionalProperties => JSON::false,
};
my $js = JSON::Schema::Validate->new( $schema )
->compile
->content_checks
->ignore_unknown_required_vocab
->prune_unknown
->register_builtin_formats
->trace
->trace_limit(200) # 0 means unlimited
->unique_keys; # enable uniqueKeys
#my $data = {
# name => 'head',
# next => {
# name => 'tail'
# }
#};
#my $data = {
# name => 23,
# next => {
# name => 'tail'
# }
#};
#my $data = {
# name => 'head',
#};
my $data = {
name => 'head big',
};
my $ok = $js->validate($data)
or die( $js->error );
print "ok\n";
This is a series of post of my experiences learning Perl web development with Vuejs. These are all the posts:
This text was translated using software. However, I wrote almost all of it myself. So please bear with me if the language sounds a bit…
-
App::Netdisco - An open source web-based network management tool.
- Version: 2.097000 on 2025-12-16, with 810 votes
- Previous CPAN version: 2.096001 was 2 days before
- Author: OLIVER
-
CPANSA::DB - the CPAN Security Advisory data as a Perl data structure, mostly for CPAN::Audit
- Version: 20251221.001 on 2025-12-21, with 25 votes
- Previous CPAN version: 20251214.001 was 7 days before
- Author: BRIANDFOY
-
Dist::Zilla::Plugin::Test::Compile - Common tests to check syntax of your modules, using only core modules
- Version: 2.059 on 2025-12-16, with 13 votes
- Previous CPAN version: 2.058 was 7 years, 11 months, 27 days before
- Author: ETHER
-
Image::ExifTool - Read and write meta information
- Version: 13.44 on 2025-12-15, with 44 votes
- Previous CPAN version: 13.36 was 3 months, 6 days before
- Author: EXIFTOOL
-
JSON::Schema::Modern - Validate data against a schema using a JSON Schema
- Version: 0.630 on 2025-12-14, with 16 votes
- Previous CPAN version: 0.629 was 2 days before
- Author: ETHER
-
List::Gen - provides functions for generating lists
- Version: 0.979 on 2025-12-21, with 24 votes
- Previous CPAN version: 0.978
- Author: SOMMREY
-
Minilla - CPAN module authoring tool
- Version: v3.1.29 on 2025-12-17, with 98 votes
- Previous CPAN version: v3.1.28 was 3 months, 2 days before
- Author: SYOHEX
-
Module::CoreList - what modules shipped with versions of perl
- Version: 5.20251220 on 2025-12-20, with 44 votes
- Previous CPAN version: 5.20251120 was 1 month before
- Author: BINGOS
-
Mouse - Moose minus the antlers
- Version: v2.6.1 on 2025-12-20, with 63 votes
- Previous CPAN version: v2.6.0 was 1 month, 20 days before
- Author: SKAJI
-
PGXN::API - Maintain and serve a REST API to search PGXN mirrors
- Version: v0.21.0 on 2025-12-15, with 18 votes
- Previous CPAN version: v0.20.2 was 1 year, 9 months before
- Author: DWHEELER
-
Sidef - The Sidef Programming Language
- Version: 25.12 on 2025-12-21, with 121 votes
- Previous CPAN version: 24.11 was 1 year, 22 days before
- Author: TRIZEN
-
Text::Markup - Parse text markup into HTML
- Version: 0.41 on 2025-12-18, with 12 votes
- Previous CPAN version: 0.40 was 3 days before
- Author: DWHEELER
-
Unicode::UTF8 - Encoding and decoding of UTF-8 encoding form
- Version: 0.63 on 2025-12-20, with 20 votes
- Previous CPAN version: 0.62 was 8 years, 8 months, 9 days before
- Author: CHANSEN
-
Zonemaster::Backend - A system for running Zonemaster tests asynchronously through an RPC-API
- Version: 12.0.0 on 2025-12-19, with 16 votes
- Previous CPAN version: 11.5.0 was 5 months, 22 days before
- Author: ZNMSTR
-
Zonemaster::Engine::Exception::NormalExit - run Zonemaster tests from the command line
- Version: 8.000001 on 2025-12-19, with 23 votes
- Previous CPAN version: 8.000000 was 5 months, 22 days before
- Author: ZNMSTR
-
Zonemaster::Engine - A tool to check the quality of a DNS zone
- Version: 8.001000 on 2025-12-19, with 35 votes
- Previous CPAN version: 8.000000 was 5 months, 22 days before
- Author: ZNMSTR
This is the weekly favourites list of CPAN distributions. Votes count: 43
Week's winner: MCP (+3)
Build date: 2025/12/21 13:03:54 GMT
Clicked for first time:
- App::BlurFill - Blurred background fill image processor
- Complete::Getopt::Long - Complete command-line argument using Getopt::Long specification
- Data::Turtle - Turtle Movement and State Operations
- Marlin - ð pretty fast class builder with most Moo/Moose features ð
- Mojo::Collection::XS - Fast XS subclass of Mojo::Collection with XS-based while
- SimpleFlow - easy, simple workflow manager (and logger); for keeping track of and debugging large and complex shell command workflows
Increasing its reputation:
- Affix (+1=4)
- App::shcompgen (+1=3)
- Complete::Bash (+1=5)
- Complete::Util (+1=2)
- Const::Fast (+1=37)
- DateTime::Format::Strptime (+1=26)
- File::HomeDir (+1=35)
- File::XDG (+1=10)
- Getopt::Long::Complete (+1=15)
- Getopt::Long::More (+1=2)
- IPC::Run3 (+1=25)
- JQ::Lite (+1=7)
- JSON::Schema::Modern (+1=9)
- JSON::XS (+1=121)
- MCP (+3=7)
- Melian (+2=3)
- MooX::Singleton (+1=6)
- OpenGL (+1=14)
- OpenGL::Modern (+1=3)
- PAGI (+2=2)
- Path::Iterator::Rule (+1=26)
- Perl::Types (+1=2)
- Prima (+1=46)
- SDL3 (+2=2)
- sealed (+1=2)
- Storage::Abstract (+2=2)
- Sub::Throttler (+1=2)
- Test2::Plugin::SubtestFilter (+1=3)
- Text::Markup (+1=12)
- Thread::Subs (+2=2)
We’ve just published a new Perl School book: Design Patterns in Modern Perl by Mohammad Sajid Anwar.
It’s been a while since we last released a new title, and in the meantime, the world of eBooks has moved on – Amazon don’t use .mobi any more, tools have changed, and my old “it mostly works if you squint” build pipeline was starting to creak.
On top of that, we had a hard deadline: we wanted the book ready in time for the London Perl Workshop. As the date loomed, last-minute fixes and manual tweaks became more and more terrifying. We really needed a reliable, reproducible way to go from manuscript to “good quality PDF + EPUB” every time.
So over the last couple of weeks, I’ve been rebuilding the Perl School book pipeline from the ground up. This post is the story of that process, the tools I ended up using, and how you can steal it for your own books.
The old world, and why it wasn’t good enough
The original Perl School pipeline dates back to a very different era:
-
Amazon wanted
.mobifiles. -
EPUB support was patchy.
-
I was happy to glue things together with shell scripts and hope for the best.
It worked… until it didn’t. Each book had slightly different scripts, slightly different assumptions, and a slightly different set of last-minute manual tweaks. It certainly wasn’t something I’d hand to a new author and say, “trust this”.
Coming back to it for Design Patterns in Modern Perl made that painfully obvious. The book itself is modern and well-structured; the pipeline that produced it shouldn’t feel like a relic.
Choosing tools: Pandoc and wkhtmltopdf (and no LaTeX, thanks)
The new pipeline is built around two main tools:
-
Pandoc – the Swiss Army knife of document conversion. It can take Markdown/Markua plus metadata and produce HTML, EPUB, and much, much more.
-
wkhtmltopdf– which turns HTML into a print-ready PDF using a headless browser engine.
Why not LaTeX? Because I’m allergic. LaTeX is enormously powerful, but every time I’ve tried to use it seriously, I end up debugging page breaks in a language I don’t enjoy. HTML + CSS I can live with; browsers I can reason about. So the PDF route is:
- Markdown → HTML (via Pandoc) → PDF (via
wkhtmltopdf)
And the EPUB route is:
- Markdown → EPUB (via Pandoc) → validated with
epubcheck
The front matter (cover page, title page, copyright, etc.) is generated with Template Toolkit from a simple book-metadata.yml file, and then stitched together with the chapters to produce a nice, consistent book.
That got us a long way… but then a reader found a bug.
The iBooks bug report
Shortly after publication, I got an email from a reader who’d bought the Leanpub EPUB and was reading it in Apple Books (iBooks). Instead of happily flipping through Design Patterns in Modern Perl, they were greeted with a big pink error box.
Apple’s error message boiled down to:
There’s something wrong with the XHTML in this EPUB.
That was slightly worrying. But, hey, every day is a learning opportunity. And, after a bit of digging, this is what I found out.
EPUB 3 files are essentially a ZIP containing:
-
XHTML content files
-
a bit of XML metadata
-
CSS, images, and so on
Apple Books is quite strict about the “X” in XHTML: it expects well-formed XML, not just “kind of valid HTML”. So when working with EPUB, you need to forget all of that nice HTML5 flexibility that you’ve got used to over the last decade or so.
The first job was to see if we could reproduce the error and work out where it was coming from.
Discovering epubcheck
Enter epubcheck.
epubcheck is the reference validator for EPUB files. Point it at an .epub and it will unpack it, parse all the XML/XHTML, check the metadata and manifest, and tell you exactly what’s wrong.
Running it on the book immediately produced this:
Fatal Error while parsing file: The element type
brmust be terminated by the matching end-tag</br>.
That’s the XML parser’s way of saying:
-
In HTML,
<br>is fine. -
In XHTML (which is XML), you must use
<br />(self-closing) or<br></br>.
And there were a number of these scattered across a few chapters.
In other words: perfectly reasonable raw HTML in the manuscript had been passed straight through by Pandoc into the EPUB, but that HTML was not strictly valid XHTML, so Apple Books rejected it. I should note at this point that the documentation for Pandoc’s EPUB creation explicitly says that it won’t touch HTML fragments it finds in a Markdown file when converting it to EPUB. It’s down to the author to ensure they’re using valid XHTML
A quick (but not scalable) fix
Under time pressure, the quickest way to confirm the diagnosis was:
-
Unzip the generated EPUB.
-
Open the offending XHTML file.
-
Manually turn
<br>into<br />in a couple of places. -
Re-zip the EPUB.
-
Run
epubcheckagain. -
Try it in Apple Books.
That worked. The errors vanished, epubcheck was happy, and the reader confirmed that the fixed file opened fine in iBooks.
But clearly:
Open the EPUB in a text editor and fix the XHTML by hand
is not a sustainable publishing strategy.
So the next step was to move from “hacky manual fix” to “the pipeline prevents this from happening again”.
HTML vs XHTML, and why linters matter
The underlying issue is straightforward once you remember it:
-
HTML is very forgiving. Browsers will happily fix up all kinds of broken markup.
-
XHTML is XML, so it’s not forgiving:
-
empty elements must be self-closed (
<br />,<img />,<hr />, etc.), -
tags must be properly nested and balanced,
-
attributes must be quoted.
-
EPUB 3 content files are XHTML. If you feed them sloppy HTML, some readers (like Apple Books) will just refuse to load the chapter.
So I added a manuscript HTML linter to the toolchain, before we ever get to Pandoc or epubcheck.
Roughly, the linter:
-
Reads the manuscript (ignoring fenced code blocks so it doesn’t complain about
<in Perl examples). -
Extracts any raw HTML chunks.
-
Wraps those chunks in a temporary root element.
-
Uses
XML::LibXMLto check they’re well-formed XML. -
Reports any errors with file and line number.
It’s not trying to be a full HTML validator; it’s just checking: “If this HTML ends up in an EPUB, will the XML parser choke?”
That would have caught the <br> problem before the book ever left my machine.
Hardening the pipeline: epubcheck in the loop
The linter catches the obvious issues in the manuscript; epubcheck is still the final authority on the finished EPUB.
So the pipeline now looks like this:
-
Lint the manuscript HTML
Catch broken raw HTML/XHTML before conversion. -
Build PDF + EPUB via
make_book-
Generate front matter from metadata (cover, title pages, copyright).
-
Turn Markdown + front matter into HTML.
-
Use
wkhtmltopdffor a print-ready PDF. -
Use Pandoc for the EPUB.
-
-
Run
epubcheckon the EPUB
Ensure the final file is standards-compliant. -
Only then do we upload it to Leanpub and Amazon, making it available to eager readers.
The nice side-effect of this is that any future changes (new CSS, new template, different metadata) still go through the same gauntlet. If something breaks, the pipeline shouts at me long before a reader has to.
Docker and GitHub Actions: making it reproducible
Having a nice Perl script and a list of tools installed on my laptop is fine for a solo project; it’s not great if:
-
other authors might want to build their own drafts, or
-
I want the build to happen automatically in CI.
So the next step was to package everything into a Docker image and wire it into GitHub Actions.
The Docker image is based on a slim Ubuntu and includes:
-
Perl +
cpanm+ all CPAN modules from the repo’scpanfile -
pandoc -
wkhtmltopdf -
Java +
epubcheck -
The Perl School utility scripts themselves (
make_book,check_ms_html, etc.)
The workflow in a book repo is simple:
-
Mount the book’s Git repo into
/work. -
Run
check_ms_htmlto lint the manuscript. -
Run
make_bookto buildbuilt/*.pdfandbuilt/*.epub. -
Run
epubcheckon the EPUB. -
Upload the
built/artefacts.
GitHub Actions then uses that same image as a container for the job, so every push or pull request can build the book in a clean, consistent environment, without needing each author to install Pandoc, wkhtmltopdf, Java, and a large chunk of CPAN locally.
Why I’m making this public
At this point, the pipeline feels:
-
modern (Pandoc, HTML/CSS layout, EPUB 3),
-
robust (lint +
epubcheck), -
reproducible (Docker + Actions),
-
and not tied to Perl in any deep way.
Yes, Design Patterns in Modern Perl is a Perl book, and the utilities live under the “Perl School” banner, but nothing is stopping you from using the same setup for your own book on whatever topic you care about.
So I’ve made the utilities available in a public repository (the perlschool-util repo on GitHub). There you’ll find:
-
the build scripts,
-
the Dockerfile and helper script,
-
example GitHub Actions configuration,
-
and notes on how to structure a book repo.
If you’ve ever thought:
I’d like to write a small technical book, but I don’t want to fight with LaTeX or invent a build system from scratch…
then you’re very much the person I had in mind.
eBook publishing really is pretty easy once you’ve got a solid pipeline. If these tools help you get your ideas out into the world, that’s a win.
And, of course, if you’d like to write a book for Perl School, I’m still very interested in talking to potential authors – especially if you’re doing interesting modern Perl in the real world.
The post Behind the scenes at Perl School Publishing first appeared on Perl Hacks.

