Skip to main content
  1. Documentation/
  2. Developer Guide/

Building Agent Modules

Table of Contents
Agent modules are compiled payloads that agents load and execute at runtime on target hosts. Unlike tool plugins (server-side Python), agent modules run inside the agent process on targets.
Not to be confused with tool plugins. Tool plugins (agentless modules) are Python classes running on the teamserver that connect to remote services directly. Agent modules are binary payloads loaded into C2 agents. See Extension Points for the full taxonomy.

Two ways to create agent modules
#

TantoC2 supports two formats for declaring agent modules:

FormatWhen to use
Python wheel package with AgentModuleBaseNew modules — preferred. Bundled as a proper Python package with entry points, payload as package data, schemas as class attributes.
YAML manifest + payload fileSimple or legacy modules. Drop a directory with manifest.yaml and the payload file into an agent_modules/ directory.

Both formats are discovered by the AgentModuleRegistry at startup and exposed identically to operators.


Python Wheel Package (Preferred)
#

Overview
#

An AgentModuleBase subclass declares module metadata and command schemas as class attributes, bundles the payload as package data, and exposes itself via an entry point.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# tantoc2/src/tantoc2/server/agent_module_base.py

class AgentModuleBase(PluginBase):
    # Class-level metadata (set by subclasses)
    name: ClassVar[str] = ""
    description: ClassVar[str] = ""
    format: ClassVar[str] = "py"      # py, bof, shellcode, dll, coff
    platforms: ClassVar[list[str]] = []
    architectures: ClassVar[list[str]] = []
    author: ClassVar[str] = ""
    version: ClassVar[str] = "0.1.0"
    mitre_attack: ClassVar[list[str]] = []
    privilege_required: ClassVar[str | None] = None
    payload_file: ClassVar[str] = ""  # relative to package data

    # Auto-discovered from Command class attributes
    _discovered_commands: ClassVar[dict[str, Command]] = {}

__init_subclass__ scans for Command class attributes and populates _discovered_commands. The payload is located at runtime via importlib.resources.

Step 1: Create the project structure
#

1
2
3
4
5
my-module/
  pyproject.toml
  src/my_module/
    __init__.py
    sysinfo.py        # payload file (Python)

Step 2: Write pyproject.toml
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "my-module"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["tantoc2"]

[project.entry-points."tantoc2.agent_modules"]
sysinfo = "my_module:SysinfoModule"

[tool.hatch.build.targets.wheel]
packages = ["src/my_module"]

Step 3: Implement AgentModuleBase
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from typing import ClassVar
from tantoc2.server.agent_module_base import AgentModuleBase
from tantoc2.server.command import Command


class SysinfoModule(AgentModuleBase):
    name = "sysinfo"
    description = "Collect detailed system information beyond basic survey data"
    format = "py"
    platforms: ClassVar[list[str]] = ["linux", "windows", "darwin"]
    architectures: ClassVar[list[str]] = ["any"]
    author = "Your Name"
    version = "1.0.0"
    mitre_attack: ClassVar[list[str]] = ["T1082"]
    payload_file = "sysinfo.py"      # located relative to this package

    # Command schema — auto-discovered by __init_subclass__
    sysinfo = Command("sysinfo", "Collect detailed system information") \
        .arg("verbose", type="bool", desc="Include extended system details", default=False)

The payload file sysinfo.py is bundled as package data and located at runtime via importlib.resources.

Step 4: Write the payload
#

For format: py, the payload must export a run(options) function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# src/my_module/sysinfo.py
import json
import platform
import socket

def run(options: dict) -> str:
    verbose = options.get("verbose", False)
    info = {
        "hostname": socket.gethostname(),
        "os": platform.system(),
        "release": platform.release(),
        "arch": platform.machine(),
    }
    if verbose:
        info["processor"] = platform.processor()
        info["python"] = platform.python_version()
    return json.dumps(info)

Reference implementation
#

Source: tantoc2/src/tantoc2/server/agent_modules/sysinfo/__init__.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class SysinfoServerModule(AgentModuleBase):
    name = "sysinfo"
    description = "Collect detailed system information beyond basic survey data"
    format = "py"
    platforms = ["linux", "windows", "darwin"]
    architectures = ["any"]
    author = "TantoC2"
    version = "1.0.0"
    mitre_attack = ["T1082"]
    payload_file = "sysinfo.py"

    sysinfo = Command("sysinfo", "Collect detailed system information") \
        .arg("verbose", type="bool", desc="Extended details", default=False)

YAML Manifest + Payload File (Legacy/Simple)
#

Lifecycle Overview
#

Module Loading Lifecycle
  1. Create a compiled payload in a format your target agent supports
  2. Write a manifest.yaml describing the payload’s metadata and options
  3. Place both in a subdirectory under agent_modules/
  4. The AgentModuleRegistry discovers and validates the manifest on startup
  5. Operators select compatible modules for a specific agent
  6. The teamserver sends a load_module task with the base64-encoded payload
  7. The agent receives the payload and executes it in managed or daemonized mode

Step 1: Write your payload
#

Create the compiled payload in whatever format your target agent supports. Common formats:

format valueExtensionDescription
bof.oBeacon Object File (COFF object)
shellcode.binPosition-independent shellcode
dll.dllWindows DLL with a specific export
coff.oCOFF object file
py.pyPython script (for the dev agent)

Any string works as a format identifier. The teamserver matches format against what the target agent declares in supported_module_formats(). A module will only appear in the compatible list if the format matches.

Step 2: Write manifest.yaml
#

Create manifest.yaml in the same directory as your payload:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# Required fields
name: port_scan
description: TCP port scanner using raw sockets
author: Your Name
format: bof

# Target platforms (linux, windows, macos, any)
platforms:
  - windows
  - linux

# Target architectures (x64, x86, arm64, any)
architectures:
  - x64

# Optional fields
version: "1.2.0"
privilege_required: false
mitre_attack:
  - T1046

# Payload file (auto-detected if omitted — first non-YAML file in dir)
payload_file: port_scan_x64.o

# Options schema
options_schema:
  target:
    type: str
    description: Target IP or CIDR range
    required: true
  ports:
    type: str
    description: "Port range (e.g., 1-1024 or 80,443,8080)"
    required: false
    default: "1-1024"
  timeout_ms:
    type: int
    description: Connection timeout in milliseconds
    required: false
    default: 500

Manifest Reference
#

Required Fields
#

FieldTypeDescription
namestrUnique module name. Must be globally unique across all agent modules.
descriptionstrHuman-readable description shown in the UI and CLI.
authorstrAuthor name.
formatstrFormat identifier — must exactly match a value in the target agent’s supported_module_formats().

Optional Fields
#

FieldTypeDefaultDescription
platformslist[str][]Target platforms: windows, linux, macos. Empty = all platforms.
architectureslist[str][]Target architectures: x64, x86, arm64, any. Empty = all arches. any matches all.
versionstr"1.0.0"Module version string.
privilege_requiredbool | strfalseWhether elevated privileges are needed.
mitre_attacklist[str][]MITRE ATT&CK technique IDs.
payload_filestrauto-detectFilename of the payload relative to the module directory. If omitted, the registry picks the first non-YAML file in the directory.
options_schemadict{}Options operators can pass when loading the module (see below).

options_schema Format
#

Each key in options_schema is an option name. The value is a dict with these fields:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
options_schema:
  my_option:
    type: str        # str, int, bool, float
    description: "What this option does"
    required: true   # or false
    default: null    # default value if not required
    choices:         # optional — static list of allowed values
      - value_a
      - value_b
    choices_from: "" # optional — dynamic source resolved client-side

The registry automatically converts your options_schema to a Command via Command.from_options_schema(). When a module is running inside an agent, its schema is merged into the capabilities endpoint response so the CLI agent shell and the web UI Tasks tab can render the correct input form and offer tab-completion.

Note: positional and entity-type arguments are not available in manifest YAML. Use AgentModuleBase if you need those features.

See Declaring Command Schemas for the full type reference.

Step 3: Directory Layout
#

Place your module in a subdirectory of agent_modules/:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
agent_modules/
  port_scan/
    manifest.yaml
    port_scan_x64.o
  screenshot/
    manifest.yaml
    screenshot_x64.o
    screenshot_x86.o    # multiple architectures — use payload_file
  reverse_shell/
    manifest.yaml
    reverse_shell_x64.bin

Each subdirectory must contain a manifest.yaml (or manifest.yml). The registry scans all subdirectories recursively.


Compatibility Filtering
#

The AgentModuleRegistry uses AgentModuleDescriptor.is_compatible() to match modules to agents on three dimensions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def is_compatible(
    self,
    module_formats: list[str],  # from agent's supported_module_formats()
    platform: str | None = None,  # agent's OS
    arch: str | None = None,      # agent's arch
) -> bool:
    if self.format not in module_formats:
        return False
    if platform and self.platforms and platform not in self.platforms:
        return False
    if arch and self.architectures and arch not in self.architectures:
        if "any" not in self.architectures:
            return False
    return True

An empty platforms or architectures list in the manifest means “all” — the filter is skipped. any in architectures matches every agent architecture.


Loading Modes
#

Managed Mode (default, daemonize=false)
#

The agent:

  1. Allocates memory and loads the payload
  2. Executes it synchronously
  3. Returns results through its C2 channel as task_result messages
  4. Tracks the module lifecycle (running, completed, failed)
  5. Supports clean unload via an unload_module task

Managed mode is the default. Long-running modules can stream results across multiple check-in cycles.

Daemonized Mode (daemonize=true)
#

The agent:

  1. Launches the payload independently (does not wait for results)
  2. Loses the managed lifecycle — there is no unload_module

If the payload is a C2 agent itself (e.g., Shinobi shellcode), it:

  1. Performs its own registration handshake with the teamserver
  2. Registers as a brand-new agent
  3. The teamserver records the parent-child relationship for P2P topology

Requires the loading agent to declare supports_daemonize() = True.


Task Payloads
#

The teamserver sends these task types when managing agent modules.

load_module
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "task_type": "load_module",
  "payload": {
    "module_name": "port_scan",
    "module_data": "<base64-encoded payload bytes>",
    "module_format": "bof",
    "daemonize": false,
    "options": {
      "target": "10.0.0.0/24",
      "ports": "22,80,443",
      "timeout_ms": 500
    }
  }
}

unload_module
#

1
2
3
4
5
6
{
  "task_type": "unload_module",
  "payload": {
    "module_name": "port_scan"
  }
}

The agent handles these task types as declared in its built-in command list.


Module Status Tracking
#

The teamserver tracks each loaded module per agent:

StatusMeaning
runningModule is loaded and executing
completedModule finished normally
failedModule execution failed
unloadedModule was cleanly unloaded by operator

Python Modules for the Dev Agent
#

The dev agent supports format: py. Python modules must export a run(options) function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# my_module/scanner.py
import json, socket

def run(options: dict) -> str:
    """Called by the dev agent with the options dict from the task payload."""
    target = options.get("target", "127.0.0.1")
    ports_raw = options.get("ports", "80,443")
    timeout_ms = int(options.get("timeout_ms", 500))

    open_ports = []
    for port_str in ports_raw.split(","):
        port = int(port_str.strip())
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.settimeout(timeout_ms / 1000)
            sock.connect((target, port))
            open_ports.append(port)
            sock.close()
        except OSError:
            pass

    return json.dumps({"target": target, "open_ports": open_ports})

Reference: Dev Agent Bundled Modules
#

The dev agent ships two bundled Python modules at dev_agent/src/tantoc2_dev_agent/:

ModuleFormatDescription
sysinfopyCollect system information (OS, hostname, interfaces)
execpyExecute a shell command and return output

These modules use YAML manifests and serve as working examples of the manifest format. The hello_world and port_check modules were removed in the pre-Stage 5 cleanup.

For the preferred Python wheel package approach, see the server-bundled reference at tantoc2/src/tantoc2/server/agent_modules/sysinfo/__init__.py.

The teamserver no longer bundles agent modules itself. All agent modules are loaded from plugins (wheel packages via tantoc2.agent_modules entry points) or from the agent_modules/ directory. The dev agent’s bundled modules are provided by the dev agent package, not the teamserver.

Bundling Modules with an Agent Package
#

Agent packages can ship bundled modules via agent_modules_dir():

1
2
3
4
5
6
7
8
9
class MyAgentPackage(AgentPackageBase):
    @classmethod
    def agent_modules_dir(cls) -> Path | None:
        # Modules live in subdirectories alongside package.py
        pkg_dir = Path(__file__).parent
        # Return the directory only if it contains at least one manifest
        if any((d / "manifest.yaml").is_file() for d in pkg_dir.iterdir() if d.is_dir()):
            return pkg_dir
        return None

The AgentModuleRegistry.discover_from_packages() call at startup scans all registered agent packages and adds their module directories automatically.


Discovery and Refresh
#

1
2
3
4
5
6
7
8
# CLI
tantoc2> agent-modules list
tantoc2> agent-modules list --agent <agent-id>    # Only compatible modules

# REST API
GET  /api/v1/agent-modules/
GET  /api/v1/agent-modules/compatible/<agent_id>
POST /api/v1/agent-modules/refresh

refresh() on the AgentModuleRegistry re-scans all directories — equivalent to a full discover(). Add new modules by dropping them in agent_modules/ and calling refresh.


Common Pitfalls
#

Format mismatch — the format field must exactly match a string in the agent’s supported_module_formats(). Typos here mean the module never appears as compatible.

Missing any in architectures — if you have a universal payload (e.g., Python script), set architectures: [any] in your manifest or architectures = ["any"] in your class. Without this, an agent with arch x64 won’t match a manifest that lists only x86.

payload_file path — the value is relative to the module directory (for manifests) or the package (for wheel packages). Do not include the directory name itself.

Empty platforms/architectures — an empty list means “all” (no filter). If you only target Windows but leave platforms: [], the module will appear as compatible for Linux agents too.

Duplicate names — if two module directories have the same name, the second one is silently skipped. Module names must be globally unique.