Developing a plugin
Once your plugin runs, the question becomes how to change it quickly and find out what went wrong when it misbehaves. This page covers the working loop: which edits need a reinstall and which don't, how to test your code without running a crawl, how to read what the host tells you, and what the common failures actually look like.
Know which edits need a reinstall
The single most useful thing to know about the development loop is that the registry stores your manifest, not your code. When you install a plugin, the host validates the manifest and records it along with the plugin's directory; when you run a crawl, it launches the command that manifest names. Your worker file is read fresh by the interpreter every time.
That split has a practical consequence worth committing to memory:
| What you changed | What you need to do |
|---|---|
worker.py or any Python your plugin imports |
Nothing. The next crawl picks it up. |
plugin.yaml—schema, extensions, command, execution defaults |
crawl plugin install ./your-plugin --force |
Forgetting the second row is a genuinely confusing experience, because the crawl runs cleanly and simply ignores the change you made. If you add an extension and the file counts don't move, or you add a schema column and the CSV header doesn't change, a stale registration is almost always the reason.
Test the processor as an ordinary function
Because your processor is a plain function that takes a path and returns a
list, most of your testing needs nothing from crawl at all. Write tests the
way you'd write them for any other Python code, and keep the fast feedback loop
where the logic actually lives.
from pathlib import Path
from worker import process
def test_counts_words(tmp_path: Path) -> None:
sample = tmp_path / "a.txt"
sample.write_text("one two three\n", encoding="utf-8")
assert process(str(sample)) == [{"filename": str(sample), "words": 3}]
def test_an_empty_file_produces_a_zero_count(tmp_path: Path) -> None:
sample = tmp_path / "empty.txt"
sample.write_text("", encoding="utf-8")
assert process(str(sample))[0]["words"] == 0
Tests like these catch the errors you'll make most often—wrong counts, wrong keys, mishandled edge cases—long before a crawl is involved. What they can't catch is a mismatch between what your function returns and what your manifest declares, since the manifest isn't in play. For that, you need the worker or a real crawl.
Talk to the worker directly
When you want to see exactly what your plugin puts on the wire, you can drive
the worker by hand. It reads JSON Lines on stdin and writes them on stdout,
so a pipe is enough:
cd your-plugin
printf '{"id":0,"op":"handshake"}\n{"id":1,"filepath":"/absolute/path/a.txt"}\n' \
| /path/to/.venv/bin/python worker.py
{"id":0,"status":"ready","api_version":"1","plugin":"word-count","sdk":"crawl-plugin-sdk/0.1.0"}
{"id":1,"status":"ok","rows":[{"filename":"/absolute/path/a.txt","words":3}]}
This is the fastest way to answer "what is my plugin actually returning?"
because you see the literal keys and types, not a summary of them. Compare
those keys against your output.schema block and most schema rejections
explain themselves. Note the two conventions the probe makes visible: paths are
absolute, and the handshake comes first.
Debug with print, and know where it goes
You can use print for debugging, but you should know what the SDK does with
it. Worker stdout is reserved for protocol messages, so the SDK takes
ownership of it at startup and redirects sys.stdout to stderr. Anything you
print therefore becomes a diagnostic rather than corrupting the stream—which is
why your output seems to vanish from stdout and appear somewhere else.
To see those diagnostics during a crawl, raise the verbosity:
crawl run word-count --input ./corpus --output ./report.csv -vv
Worker output arrives as worker_diagnostic events alongside the host's own
per-file tracing. If you'd rather have structured output to search or archive,
add --log crawl.log, which writes every event as JSON regardless of what the
terminal shows.
Two flags make debugging noticeably easier when something fails intermittently.
Running with --workers 1 removes interleaving, so the log reads in order, and
--fail-fast stops at the first error instead of burying it under thousands of
later lines.
Add a self-test
A self-test gives your plugin a way to check itself at registration time, which
is useful when the plugin depends on something outside its own code—a model
file, a compiled parser, a locale. Declare it in the manifest and pass it to
run:
runtime:
type: python
command: ["/path/to/.venv/bin/python", "worker.py"]
selftest: true
def selftest() -> None:
"""Verify the extractor against a known input."""
import tempfile
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as handle:
handle.write("one two three\n")
assert process(handle.name)[0]["words"] == 3
if __name__ == "__main__":
raise SystemExit(run(process, plugin="word-count", selftest=selftest))
crawl plugin validate word-count now runs that function inside a real worker
process and reports the result. Because it runs in the same environment the
crawl will use, it catches environment problems that a unit test on your own
machine would miss.
Common failures and what they mean
The host tries to tell you what happened rather than that something happened. The table below maps the messages you're most likely to see to their usual cause.
| What you see | What it usually means |
|---|---|
ModuleNotFoundError inside a handshake failure |
runtime.command names an interpreter that can't import your dependencies. Point it at the virtual environment where you installed them. |
record contains undeclared field "x" |
Your processor returns a key the schema doesn't declare. Add it to output.schema, remove it from the record, or set extra_fields: ignore. |
required field "x" is missing |
A declared required field is absent from a record. Return it, or mark it required: false if it's genuinely optional. |
field "x" expected integer but received string |
A type mismatch. The host never silently coerces, so "3" won't satisfy an integer column. |
contains a nested object; v1 records must be flat |
You returned a dict or list as a value. Serialize it to a string, and declare that column as string. |
plugin exceeded the Ns per-file timeout |
One file took too long. Raise --timeout, or find the pathological input—the log names the file. |
Files matched is 0 |
Your extensions don't match. Matching is case-insensitive and uses only the final extension, so .tar.gz never matches; use .gz. |
Nothing changed after editing plugin.yaml |
The registry still holds the old manifest. Reinstall with --force. |
Fail one file without failing the crawl
When your plugin genuinely can't process a file, say so explicitly. Raising
PluginProcessingError gives the host a code and a message it can attribute to
that file, and the crawl continues with everything else.
raise PluginProcessingError("not_utf8_text", f"{path} is not UTF-8 text")
You don't have to catch everything, though. Any exception you don't handle is
caught by the SDK, reported as unhandled_exception with a traceback, and
costs you the same one file. The difference is diagnostic quality rather than
crash safety: a named error tells you and your users something specific,
whereas a traceback tells them your plugin has a bug. Reserve explicit errors
for conditions you expect, and let genuine bugs surface as tracebacks.
Write for reuse across many files
One last habit pays off at scale. Your worker process is reused for many
files—that's the reason a crawl doesn't pay Python's startup cost per file—so
work you do at import time happens once, while work inside process happens
per file.
import re
# Compiled once, when the worker starts.
ENTITY = re.compile(r"\b[A-Z][a-zA-Z]+\b")
def process(path: str) -> list[dict[str, object]]:
# Runs once per file.
...
Load models, compile patterns, and open shared resources at module level. Just remember that anything you keep between calls persists for the life of the worker, so avoid accumulating state that grows with every file processed—that is exactly the unbounded memory growth the host's streaming design is built to avoid.
Where to go next
For the exact meaning of every manifest field, see the manifest reference. If you're implementing a worker in another language, or want to understand a protocol error in detail, the protocol reference documents the wire format the host and worker share.