Writing a plugin

Two plugins that solve different parts of media work. One adds a callable tool. The other packages a procedure, its code, and its tests.

Put the bundle in your project

Create .kcode/plugins/media-tools/ or .kcode/plugins/gif-maker/ in your project and save the files below, keeping their relative paths. Project trust and plugin policy determine whether they load. Install FFmpeg and FFprobe; the GIF skill also needs Python 3. Neither plugin installs dependencies for you.

Validate the directory with kcode plugins validate <directory> before using it. A plugin is the installed package; a skill is a reusable task bundle that a plugin can contain. Guide-driven skills are advisory: their declared inputs are validated, but execution locks and automatic final-output validation apply only to executable skills.

media-tools

Inspect a media file with Lua

Let the agent check duration, codecs, dimensions, and audio properties before it chooses how to process a file.

How the files work together

plugin.toml declares the plugin and its run-tool capability. main.lua registers media_probe, quotes the local path, runs FFprobe through the session shell, and parses the result. The shell still uses normal session permissions.

Try it

Ask the agent to inspect a local video with media_probe, passing its path as source. The result contains format and streams. Use media_trim to write a new MP4 or media_extract_audio to write a PCM WAV. Both take source, output, start (default 0), and duration (up to 600 seconds), refuse existing outputs, and return measured output properties. The output parent directory must exist.

kcode plugins validate .kcode/plugins/media-tools

Validated with kcode and tested through its Lua runtime against real FFprobe, including a quoted filename and a missing file. The test uses a shell adapter; it does not test the interactive permission UI.

media-tools/plugin.toml

name = "media-tools"
version = "0.1.0"
description = "Inspect video and audio files using FFprobe"
kind = "Lua"

[capabilities]
"run-tool" = true

media-tools/main.lua

-- FFprobe runs through the session shell and its normal permission checks.
local function quote(value)
  assert(type(value) == "string" and #value > 0, "source must be a non-empty path")
  assert(not value:find("%z"), "source cannot contain a NUL byte")
  return "'" .. value:gsub("'", "'\\''") .. "'"
end

local function probe(args)
  local source = args.source
  -- Prefix relative paths so a filename cannot be interpreted as a protocol URL.
  assert(type(source) == "string" and #source > 0, "source must be a non-empty path")
  if source:sub(1, 1) ~= "/" then source = "./" .. source end
  local command = "ffprobe -v error -show_entries format=duration,size:stream=index,codec_type,codec_name,width,height,avg_frame_rate,sample_rate,channels -of default=noprint_wrappers=0 " .. quote(source)
  local response = kcode.run_tool("bash", { command = command, timeout_seconds = 30 })
  local result = response.structured
  assert(type(result) == "table", "shell returned no structured result")
  assert(not response.truncated and not result.truncated, "ffprobe output was truncated")
  assert(result.exit_code == 0, "ffprobe failed: " .. tostring(result.stderr or "no exit status; process may still be running"))
  local streams, format, current = {}, {}, nil
  for line in (result.stdout or ""):gmatch("[^\r\n]+") do
    if line == "[STREAM]" then
      current = {}
      streams[#streams + 1] = current
    elseif line == "[FORMAT]" then current = format
    elseif line == "[/STREAM]" or line == "[/FORMAT]" then current = nil
    elseif current then
      local key, value = line:match("^([^=]+)=(.*)$")
      if key then current[key] = tonumber(value) or value end
    end
  end
  assert(#streams > 0, "file contains no media streams")
  return { source = args.source, format = format, streams = streams }
end

kcode.register_tool("media_probe", "Inspect a local media file: duration, size, codecs, video dimensions and audio sample rate. Requires ffprobe.", probe, {
  schema = {
    type = "object",
    properties = { source = { type = "string", description = "Local file path, relative to the project or absolute" } },
    required = { "source" },
    additionalProperties = false,
  },
  read_only = true,
})

local function local_path(value)
  quote(value) -- Validate before prefixing.
  return value:sub(1, 1) == "/" and value or "./" .. value
end

local function convert(args, audio)
  local source, output = local_path(args.source), local_path(args.output)
  local suffix = audio and ".wav" or ".mp4"
  assert(output:sub(-#suffix):lower() == suffix, "output must end in " .. suffix)
  local start, duration = args.start or 0, args.duration
  assert(type(start) == "number" and start >= 0 and start < math.huge, "start must be finite and non-negative")
  assert(type(duration) == "number" and duration > 0 and duration <= 600, "duration must be between 0 and 600 seconds")
  local input = probe({ source = args.source })
  assert(type(input.format.duration) == "number" and start < input.format.duration, "start is outside the input duration")
  local wanted = audio and "audio" or "video"
  local found = false
  for _, stream in ipairs(input.streams) do if stream.codec_type == wanted then found = true end end
  assert(found, "input contains no " .. wanted .. " stream")
  -- Stage beside the output, then publish with an exclusive hard link. Never
  -- overwrite an existing file or symlink, even if another call races us.
  local command = "set -eu\noutput=" .. quote(output) .. "\n"
    .. "test ! -e \"$output\" && test ! -L \"$output\"\n"
    .. "stage=$(mktemp -d \"${output}.tmp.XXXXXX\")\n"
    .. "trap 'rm -rf -- \"$stage\"' EXIT\n"
    .. "ffmpeg -nostdin -v error -ss " .. tostring(start) .. " -i " .. quote(source)
    .. " -t " .. tostring(duration)
    .. (audio and " -map 0:a:0 -vn -c:a pcm_s16le -f wav" or " -map 0:v:0 -map '0:a:0?' -c:v libx264 -pix_fmt yuv420p -c:a aac -movflags +faststart -f mp4")
    .. " \"$stage/result\"\n"
    .. "ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 \"$stage/result\" | awk '{if ($1 > 0) ok=1} END {exit !ok}'\n"
    .. "ln \"$stage/result\" \"$output\"\n"
  local response = kcode.run_tool("bash", { command = command, timeout_seconds = 120 })
  local result = response.structured
  assert(type(result) == "table", "shell returned no structured result")
  assert(result.exit_code == 0, "conversion did not complete: " .. tostring(result.stderr or "inspect the shell execution before retrying"))
  local measured = probe({ source = args.output })
  return { output = args.output, format = measured.format, streams = measured.streams }
end

local schema = {
  type = "object",
  properties = {
    source = { type = "string", description = "Local source file" },
    output = { type = "string", description = "New output path; parent directory must exist" },
    start = { type = "number", description = "Start time in seconds, default 0" },
    duration = { type = "number", description = "Maximum clip length in seconds, greater than 0 and at most 600" },
  },
  required = { "source", "output", "duration" },
  additionalProperties = false,
}
kcode.register_tool("media_trim", "Trim a local video into a new H.264/AAC MP4. Requires FFmpeg with libx264. Refuses overwrite; returns measured output properties.", function(args) return convert(args, false) end, { schema = schema, read_only = false })
kcode.register_tool("media_extract_audio", "Extract a section of local media into a new PCM WAV. Requires FFmpeg. Refuses overwrite; returns measured output properties.", function(args) return convert(args, true) end, { schema = schema, read_only = false })
gif-maker

Teach the agent to make GIFs

Turn an existing video or a numbered image sequence into a looping GIF for documentation, demos, or a bug report.

How the files work together

plugin.toml installs a Content bundle without a runtime. The make-gif skill’s guide tells the agent when to use it and how to select a clip. make_gif.py handles palette generation, encoding, and output checks. test_make_gif.py checks real conversions. The agent reads the guide and runs the script through the session shell.

Try it

Ask the agent to use make-gif with a local video, a new .gif output path, and the part of the clip you want. The skill returns the output path, dimensions, frame count, duration, and size. It refuses to overwrite an existing GIF.

python3 -B .kcode/plugins/gif-maker/skills/make-gif/test_make_gif.py

The Content bundle passes kcode validation. Script tests pass for video and image-sequence inputs, measured output properties, invalid settings, and overwrite refusal. This is guide-driven, not an automatically executed entrypoint.

gif-maker/plugin.toml

name = "gif-maker"
version = "0.1.0"
description = "Make animated GIFs from local videos or numbered image sequences"
kind = "Content"

gif-maker/skills/make-gif/SKILL.md

---
name: make-gif
description: Make an animated GIF from an existing local video or numbered PNG/JPEG images for documentation, demos, or bug reports.
tool-dependencies: [bash]
parallelizable: true
---

# Make an animated GIF

Produce a GIF and report its path, dimensions, frame count, duration, and size. Do not create an animation from a description: this skill converts existing media.

## Choose the inputs

- `source`: a local video, or a directory of PNG/JPEG images. Images must use one extension and sort in playback order (use zero-padded names such as `frame-0001.png`).
- `output`: a new `.gif` path inside the project. Never overwrite an existing output.
- `fps`: 1–30, default 10. For an image sequence this also sets how long each image plays.
- `width`: 16–1920 pixels, default 640. Aspect ratio is preserved.
- `start`: video start time in seconds, default 0. Not applicable to image sequences.
- `duration`: maximum output duration, 0.1–60 seconds, default 5. Longer input is deliberately clipped.

Ask for the section of interest if it is unclear. For documentation, start with a short clip at 640px and 10 fps; increase detail only when small text or motion needs it. For image input, use matching image dimensions and compute `duration` from image count / fps when the whole sequence is wanted.

## Convert

Requires Python 3, FFmpeg, and FFprobe. The plugin does not install them.

Run the bundled converter using the session's permission-checked shell. Quote each shell argument safely, including paths with spaces or apostrophes.

```sh
python3 "${CLAUDE_SKILL_DIR}/make_gif.py" --source "<source>" --output "<output>" --fps 10 --width 640 --start 0 --duration 5
```

The script uses subprocess argument arrays rather than shell interpolation, creates a palette for the clip, and encodes a looping GIF. It checks the output contains at least two frames before publishing it. A separate output path per call permits parallel runs.

## Check and return

Read the JSON result. Confirm the duration and dimensions suit the request. Report file size; if the user supplied a size budget, reduce dimensions or frame rate and create a new output rather than claiming an oversized GIF meets it. Inspect representative frames if needed, but do not claim you watched playback from a single still image. Return the output path and measured properties to the calling workflow.

A failed conversion is a failure, not an empty successful result. This is guide-driven: kcode supplies the guide, and the agent runs the script. No automatic script entrypoint or full schema-enforcement guarantee is assumed.

gif-maker/skills/make-gif/make_gif.py

"""Convert a local video or numbered PNG/JPEG sequence to a palette-optimized GIF."""
import argparse
import json
import math
from pathlib import Path
import shutil
import subprocess
import tempfile


def make_gif(source, output, fps=10, width=640, start=0, duration=5):
    source = Path(source).resolve(strict=True)
    output = Path(output).absolute()
    if output.suffix.lower() != '.gif' or output.exists():
        raise ValueError('output must be a new .gif file')
    if not isinstance(width, int) or not 16 <= width <= 1920:
        raise ValueError('width must be between 16 and 1920')
    for name, value, low, high in [('fps', fps, 1, 30), ('start', start, 0, 86400), ('duration', duration, .1, 60)]:
        if not math.isfinite(value) or not low <= value <= high:
            raise ValueError(f'{name} must be between {low} and {high}')
    for tool in ['ffmpeg', 'ffprobe']:
        if not shutil.which(tool):
            raise ValueError(f'{tool} is not installed')
    output.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.TemporaryDirectory(prefix='.make-gif-', dir=output.parent) as temp:
        temp = Path(temp)
        if source.is_dir():
            if start != 0:
                raise ValueError('start applies only to video input')
            images = sorted(p for p in source.iterdir() if p.suffix.lower() in ['.png', '.jpg', '.jpeg'] and p.is_file())
            if not images or len(images) > 1800:
                raise ValueError('image directory must contain 1 to 1800 PNG/JPEG files')
            if len({p.suffix.lower() for p in images}) != 1:
                raise ValueError('image sequence must use the same file extension')
            suffix = images[0].suffix.lower()
            for i, image in enumerate(images):
                (temp / f'input-{i:05d}{suffix}').symlink_to(image)
            inputs = ['-framerate', str(fps), '-i', str(temp / f'input-%05d{suffix}')]
        else:
            inputs = ['-ss', str(start), '-i', str(source)]
        target = temp / 'result.gif'
        filters = f'fps={fps},scale={width}:-1:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=sierra2_4a'
        subprocess.run(['ffmpeg', '-nostdin', '-v', 'error', *inputs, '-t', str(duration), '-filter_complex', filters, '-an', '-loop', '0', str(target)], check=True, capture_output=True, timeout=120)
        probe = subprocess.run(['ffprobe', '-v', 'error', '-count_frames', '-show_entries', 'stream=width,height,nb_read_frames:format=duration', '-of', 'json', str(target)], check=True, capture_output=True, text=True, timeout=30)
        info = json.loads(probe.stdout)
        stream = info['streams'][0]
        if int(stream['nb_read_frames']) < 2:
            raise ValueError('input selection produced fewer than two frames')
        # Exclusive creation refuses collisions, including output symlinks.
        with output.open('xb') as dest:
            try:
                with target.open('rb') as src:
                    shutil.copyfileobj(src, dest)
            except BaseException:
                output.unlink()
                raise
        return {'file': str(output), 'width': stream['width'], 'height': stream['height'], 'frames': int(stream['nb_read_frames']), 'duration_seconds': float(info['format']['duration']), 'size_bytes': output.stat().st_size}


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--source', required=True)
    parser.add_argument('--output', required=True)
    parser.add_argument('--fps', type=float, default=10)
    parser.add_argument('--width', type=int, default=640)
    parser.add_argument('--start', type=float, default=0)
    parser.add_argument('--duration', type=float, default=5)
    args = parser.parse_args()
    try:
        print(json.dumps(make_gif(**vars(args))))
    except (ValueError, OSError, subprocess.SubprocessError, KeyError, IndexError) as error:
        parser.exit(1, f'make-gif: {error}\n')

gif-maker/skills/make-gif/test_make_gif.py

import importlib.util
from pathlib import Path
import subprocess
import tempfile
import unittest

spec = importlib.util.spec_from_file_location('make_gif', Path(__file__).with_name('make_gif.py'))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)

class GifTests(unittest.TestCase):
    def test_video_and_images(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            video = root / "clip 'one'.mp4"
            subprocess.run(['ffmpeg', '-nostdin', '-v', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=160x90:rate=10:duration=2', '-c:v', 'mpeg4', str(video)], check=True)
            result = module.make_gif(video, root / 'video.gif', fps=5, width=160, duration=1)
            self.assertEqual(result['frames'], 5)
            self.assertEqual(result['width'], 160)
            self.assertEqual(result['height'], 90)
            self.assertAlmostEqual(result['duration_seconds'], 1, places=1)
            self.assertEqual(Path(result['file']).read_bytes()[:6], b'GIF89a')
            images = root / 'images'
            images.mkdir()
            subprocess.run(['ffmpeg', '-nostdin', '-v', 'error', '-i', str(video), '-vf', 'fps=2', str(images / 'frame-%03d.png')], check=True)
            result = module.make_gif(images, root / 'sequence.gif', fps=2, width=160, duration=2)
            self.assertEqual(result['frames'], 4)
            with self.assertRaises(ValueError):
                module.make_gif(video, root / 'video.gif')
            for kwargs in [{'fps':0}, {'width':0}, {'duration':float('nan')}, {'start':-1}]:
                with self.assertRaises(ValueError):
                    module.make_gif(video, root / 'bad.gif', **kwargs)
            self.assertFalse((root / 'bad.gif').exists())
            with self.assertRaises((ValueError, subprocess.CalledProcessError)):
                module.make_gif(video, root / 'past-end.gif', start=100)
            self.assertFalse((root / 'past-end.gif').exists())

if __name__ == '__main__':
    unittest.main()

Send an update or query local issues

Send a remote notification with Lua using a configured ntfy topic. The tool runs through session shell permissions and reports server acceptance.

Connect a read-only SQLite issue database through MCP. A local Python server exposes one query tool and uses parameterized SQL. Both examples include source and have passed kcode runtime integration tests.

Write files from Wasm

The Wasm host provides kcode.fs_write(path_ptr, path_len, bytes_ptr, bytes_len) during tool invocation. It writes raw bytes rather than base64, so a compiled renderer can save a PNG directly. Declare the allowed output paths with "fs.write" = ["out/**"] in the manifest’s capabilities table.

Writes are capped at 512 MiB and checked against the project root and declared paths. The separate default Wasm memory limit is 64 MiB; larger buffers require a higher configured limit. The vector-only SVG renderer compiles resvg into the module and has passed native pixel tests and kcode Wasm invocation tests.

Build the SVG renderer and read its source