Skip to content

Build your first plugin

This walkthrough takes you from an empty directory to a finished CSV report. The plugin you'll build counts the words in every text file it's given, which is simple enough to stay out of the way while you learn the moving parts. Along the way you'll meet the two things that trip up most people the first time: choosing the right Python interpreter, and getting the output schema to match what your code actually returns.

You'll need a built crawl binary and a Python virtual environment with the plugin SDK installed. If you haven't done that yet, the getting started page covers it in a few commands. Set aside about fifteen minutes.

Step 1: Create the plugin directory

A plugin is a directory containing a manifest and whatever code that manifest points to. Nothing about the location is special, so put it wherever you keep projects.

mkdir word-count && cd word-count

The host will treat this directory as your plugin's root, which matters in a practical way: it becomes the working directory of your worker process. That's what lets the manifest refer to worker.py rather than an absolute path, and it's why relative paths inside your plugin behave the way you'd expect.

Step 2: Write the processor

Start with the code, because the manifest describes it and is easier to write second. Create worker.py:

"""Count the words in a text file."""

from crawl_plugin_sdk import PluginProcessingError, run


def process(path: str) -> list[dict[str, object]]:
    """Return one record giving the word count for this file."""
    try:
        with open(path, encoding="utf-8") as handle:
            text = handle.read()
    except UnicodeDecodeError as error:
        raise PluginProcessingError(
            "not_utf8_text", f"{path} is not UTF-8 text"
        ) from error

    return [{"filename": path, "words": len(text.split())}]


if __name__ == "__main__":
    raise SystemExit(run(process, plugin="word-count"))

Three things in that file are worth pausing on, because they're the pattern you'll follow in every plugin you write. The process function receives one absolute path and returns a list of dictionaries, one per output row; returning an empty list is perfectly valid and simply means the file produced nothing. The PluginProcessingError marks a file your plugin can't handle, which fails that file and lets the crawl continue rather than taking everything down with it. The call to run at the bottom is the entire worker loop—it reads requests, calls your function, encodes responses, and translates unexpected exceptions into failure reports.

Notice what isn't there. There's no argument parsing, no loop, no JSON, and no error handling for anything other than the one failure mode you care about. If your code raises an exception you didn't anticipate, the SDK catches it, reports that one file as failed, and includes the traceback in the log.

Step 3: Declare the manifest

Now describe the plugin to the host. Create plugin.yaml:

api_version: "1"

plugin:
  name: word-count
  version: "1.0.0"
  description: Count the words in each text file

runtime:
  type: python
  command: ["python3", "worker.py"]

input:
  extensions: [".txt", ".md"]

output:
  format: records
  schema:
    filename:
      type: string
      required: true
    words:
      type: integer
      required: true

The output.schema block is the part to get right, because it becomes the CSV columns in exactly the order you declare them. Each key must match a key your process function returns, and each declared type must match the value you actually produce. Returning a key you didn't declare is an error by default, which is stricter than most people expect and is the subject of Step 6.

The runtime.command line deserves equal attention for a different reason: it names the program the host will execute, and python3 here means whichever python3 is first on the path. That's almost certainly not the virtual environment where you installed the SDK, which brings us to the step where things usually go wrong.

Step 4: Point the manifest at the right interpreter

Your worker imports crawl_plugin_sdk, so it has to run under a Python that can find it. The host doesn't create or manage environments for you—it runs the command you declare—so the manifest has to name an interpreter that has your dependencies installed.

Change the command to the absolute path of your virtual environment's interpreter:

runtime:
  type: python
  command: ["/absolute/path/to/.venv/bin/python", "worker.py"]

If you skip this step, installation fails with a message that tells you exactly what happened, which is worth seeing once so you recognize it later:

error: plugin worker failed its handshake: plugin worker exited unexpectedly:
worker closed its protocol stdout; worker stderr: Traceback (most recent call
last): | File "worker.py", line 3, in <module> | from crawl_plugin_sdk import
run | ModuleNotFoundError: No module named 'crawl_plugin_sdk'

The host surfaces your worker's own traceback rather than hiding it behind a generic failure, so the fix is usually visible in the error itself. Once the interpreter is right, you're ready to register the plugin.

Step 5: Install and validate

Installing a plugin does more than record its name. The host parses and validates the manifest, starts your worker, and completes a handshake with it, so a plugin that can't run is rejected now rather than in the middle of a million-file crawl.

crawl plugin install ./word-count

A successful install prints a one-line summary:

installed word-count@1.0.0 (2 extensions, 2 schema fields)

You can re-run the same checks at any time with crawl plugin validate word-count, which is the fastest way to confirm the plugin still starts after you've changed its code or moved its environment. Now that the host knows about your plugin, you can run it against real files.

Step 6: Run a crawl

Create a few files to process, then run the crawl:

mkdir -p corpus
printf 'one two three\n' > corpus/a.txt
printf 'four five\n' > corpus/b.md
printf 'ignored\n' > corpus/c.rs

crawl run word-count --input ./corpus --output ./report.csv

The summary tells you what happened, and the report holds the rows:

crawl 8f3c… finished: success
  files discovered : 3
  files matched    : 2
  files completed  : 2
  records emitted  : 2
filename,words
/absolute/path/corpus/a.txt,3
/absolute/path/corpus/b.md,2

Two details in that output are worth connecting back to the manifest. Three files were discovered but only two matched, because c.rs isn't one of the extensions you declared. And the columns are filename,words in that order because that's the order you wrote them in output.schema, not the order your dictionary happened to use.

Step 7: See what a schema mismatch looks like

Since the schema is the one contract the host enforces, it's worth breaking it deliberately once so the failure is familiar. Change worker.py to return wordcount instead of words, reinstall with crawl plugin install ./word-count --force, and run the crawl again.

The crawl completes, but the summary reports rejected records and the log explains why:

WARN record rejected event="record_rejected" category="schema"
  file="/absolute/path/corpus/a.txt" row=0 field="wordcount"
  record contains undeclared field "wordcount"

This is the behavior to internalize: a schema mismatch doesn't crash anything. The crawl finishes, the offending rows are dropped, the summary counts them, and the process exits with status 1 for partial success rather than 0. That design keeps one bad plugin from destroying a long run, but it also means a silent typo costs you rows, so it pays to check the summary rather than only the exit code.

Change the key back to words, reinstall, and confirm you're back to a clean run.

Where to go next

You now have a working plugin and have seen its most common failure. From here, Developing a plugin covers the iteration loop—testing your processor without running a crawl, debugging a worker that won't start, and reading the structured log. When you need the precise meaning of a manifest field or a protocol message, the manifest and protocol references have the details.