DEV Community

Cover image for The Perl Claude Agent
LNATION for LNATION

Posted on

The Perl Claude Agent

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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' }],
        };
    }
);
Enter fullscreen mode Exit fullscreen mode

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"
            }],
        };
    }
);
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 content array. Each element should have type => 'text' and a text field.
  • Error handling: Set is_error => 1 in 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 enum constraint in customer_stats limits 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();
        },
    ],
);
Enter fullscreen mode Exit fullscreen mode

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',
);
Enter fullscreen mode Exit fullscreen mode

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();
    }],
);
Enter fullscreen mode Exit fullscreen mode
  • 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 allow or deny, 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 enable CLAUDE_AGENT_DEBUG=1 to see the full stack trace.

  • The context object is your friend. The $context parameter 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')
);
Enter fullscreen mode Exit fullscreen mode

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)),
            }],
        };
    }
);
Enter fullscreen mode Exit fullscreen mode

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();
        },
    ],
);
Enter fullscreen mode Exit fullscreen mode

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 }],
        };
    }
);
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)