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.
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-toolsValidated 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" = truemedia-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 })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.pyThe 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.