← Writing a plugin
Extend kcode

Render an SVG to PNG

Give the agent a local vector drawing and an output path. This plugin compiles resvg into WebAssembly, reads SVG text through kcode, and saves PNG bytes without a shell or a rendering service.

The manifest declares filesystem access. The Rust renderer checks the input and produces pixels; the Wasm bindings register the tool and pass bytes to the host. Native tests check pixels and unsupported inputs. A separate kcode runtime test invokes the compiled module repeatedly and checks real PNG output.

This vector-only example rejects text and images: convert text to paths first. It limits input to 1 MiB and output to four million pixels. Existing output files are overwritten. It requires a kcode build with the Wasm binary-write API and read-scope wiring.

svg-renderer/README.md

# Render an SVG to PNG

This Wasm plugin compiles resvg into the module. It registers `render_svg`, reads SVG text through the host, renders locally, and writes PNG bytes through `kcode.fs_write`. It does not invoke a shell or contact a rendering service.

## Build and install

Requires Rust and the new Wasm binary-write host API in kcode (commit `222c2e46` or later). Older kcode builds cannot load the module's `fs_write` import.

```sh
rustup target add wasm32-unknown-unknown
cargo test --locked
cargo build --locked --release --target wasm32-unknown-unknown
cp target/wasm32-unknown-unknown/release/svg_renderer.wasm main.wasm
```

Copy `plugin.toml` and `main.wasm` into `<project>/.kcode/plugins/svg-renderer/`. Validate with `kcode plugins validate <directory>`. Project trust and plugin policy apply.

Ask the agent to call `render_svg`:

```json
{"source":"drawing.svg","output":"out/drawing.png"}
```

The result includes `output`, `width`, `height`, and `size_bytes`. The host creates output parents and overwrites existing output files. Choose a new path if the original must be preserved.

## What the files do

- `plugin.toml` declares Wasm and filesystem capabilities; writes are limited to `out/**`
- `src/lib.rs` parses and renders the SVG, enforces bounds, and tests pixels and failures
- `src/wasm.rs` implements registration, guest-memory allocation, host reads/writes, and JSON tool results
- `Cargo.lock` pins dependencies for repeatable builds

The Wasm runtime currently registers a tool description, not a detailed per-tool JSON schema. The description names the arguments and the Rust deserializer validates them.

## Limits

This is a vector-only example: text, images, scripts, foreign objects, and external hrefs are rejected. Convert text to paths first. Image resolvers are disabled; no host fonts or external images are loaded. Input is limited to 1 MiB and output to four million pixels. Complex SVGs can still hit the host's fuel, memory, or execution limits and return a failure.

The host's file-write cap is 512 MiB, separate from the default 64 MiB Wasm linear-memory limit. This example deliberately uses a smaller pixel bound; increasing the file-write cap does not increase the module's memory budget.

## Runtime verification

The kcode repository includes an opt-in test against this compiled module:

```sh
KCODE_SVG_PLUGIN_DIR=/absolute/path/to/svg-renderer cargo test -p plugin-runtime-wasm --test svg_renderer -- --ignored --nocapture
```

It invokes the module repeatedly, reads real PNG files back, checks their dimensions, and checks malformed SVG failure. Its filesystem adapters are test ports; the host's project-root and path-scope restrictions have separate filesystem tests.

svg-renderer/plugin.toml

name = "svg-renderer"
version = "0.1.0"
description = "Render local vector SVGs to PNG using resvg compiled into Wasm"
kind = "Wasm"

[capabilities]
"fs.read" = ["**/*.svg", "*.svg"]
"fs.write" = ["out/**"]

svg-renderer/Cargo.toml

[package]
name = "svg-renderer"
version = "0.1.0"
edition = "2024"
publish = false

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
resvg = { version = "0.45", default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
roxmltree = "0.20"

[profile.release]
opt-level = "s"
lto = true

[workspace]

svg-renderer/src/lib.rs

//! Bounded vector-only SVG rendering shared by native tests and the Wasm tool.
#[cfg(target_arch = "wasm32")]
mod wasm;

#[cfg(test)]
mod tests {
    #[test]
    fn renders_pixels_and_rejects_unsupported_inputs() {
        let (png, w, h) = super::render(r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><path fill="red" d="M0 0H20V10H0Z"/></svg>"#).unwrap();
        assert_eq!((w, h), (20, 10));
        let image = resvg::tiny_skia::Pixmap::decode_png(&png).unwrap();
        assert_eq!(image.pixel(5, 5).unwrap().red(), 255);
        for body in ["<text>Hello</text>", "<image href='file:///etc/passwd'/>", "<use href='other.svg#x'/>"] {
            assert!(super::render(&format!("<svg xmlns='http://www.w3.org/2000/svg' width='20' height='10'>{body}</svg>")).is_err());
        }
        assert!(super::render("not SVG").is_err());
        assert!(super::render("<svg xmlns='http://www.w3.org/2000/svg' width='5000' height='5000'/>").is_err());
    }
}
pub fn render(source: &str) -> Result<(Vec<u8>, u32, u32), String> {
    if source.len() > 1024 * 1024 { return Err("SVG exceeds 1 MiB".into()); }
    let doc = roxmltree::Document::parse(source).map_err(|e| e.to_string())?;
    for node in doc.descendants().filter(|n| n.is_element()) {
        if matches!(node.tag_name().name(), "text" | "image" | "feImage" | "foreignObject" | "script") {
            return Err("vector-only example: text, images, scripts and foreignObject are unsupported; convert text to paths".into());
        }
        for attr in node.attributes() {
            if attr.name() == "href" && !attr.value().starts_with('#') {
                return Err("external resource references are unsupported".into());
            }
        }
    }
    let options = resvg::usvg::Options {
        image_href_resolver: resvg::usvg::ImageHrefResolver {
            resolve_data: Box::new(|_, _, _| None),
            resolve_string: Box::new(|_, _| None),
        },
        ..Default::default()
    };
    let tree = resvg::usvg::Tree::from_str(source, &options).map_err(|e| e.to_string())?;
    let size = tree.size().to_int_size();
    if u64::from(size.width()) * u64::from(size.height()) > 4_000_000 {
        return Err("SVG exceeds four million pixels".into());
    }
    let mut image = resvg::tiny_skia::Pixmap::new(size.width(), size.height()).ok_or("cannot allocate image")?;
    resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut image.as_mut());
    Ok((image.encode_png().map_err(|e| e.to_string())?, size.width(), size.height()))
}

svg-renderer/src/wasm.rs

use serde::Deserialize;
#[link(wasm_import_module = "kcode")]
unsafe extern "C" {
    fn register_tool(np: *const u8, nl: usize, dp: *const u8, dl: usize);
    fn set_invoke_result(p: *const u8, n: usize, error: i32);
    fn fs_read(p: *const u8, n: usize) -> u64;
    fn fs_write(pp: *const u8, pn: usize, bp: *const u8, bn: usize);
}
#[unsafe(no_mangle)]
pub extern "C" fn __alloc(n: usize) -> *mut u8 {
    Box::into_raw(vec![0u8; n].into_boxed_slice()) as *mut u8
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn __free(p: *mut u8, n: usize) {
    unsafe { drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(p, n))); }
}
#[unsafe(no_mangle)]
pub extern "C" fn register() {
    let name = "render_svg";
    let desc = "Render a local vector-only SVG to PNG. Arguments: source (project-relative SVG path), output (project-relative .png path within out/). Rejects text/images/external resources, SVG over 1 MiB or four million pixels. Existing output is overwritten. Returns output, width, height, size_bytes.";
    unsafe { register_tool(name.as_ptr(), name.len(), desc.as_ptr(), desc.len()); }
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Args { source: String, output: String }
fn run(bytes: &[u8]) -> Result<Vec<u8>, String> {
    let args: Args = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
    if args.source.is_empty() || !args.output.ends_with(".png") { return Err("source is required and output must end in .png".into()); }
    let packed = unsafe { fs_read(args.source.as_ptr(), args.source.len()) };
    let p = (packed >> 32) as u32 as *mut u8;
    let n = packed as u32 as usize;
    let source = unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(p, n)) };
    let text = std::str::from_utf8(&source).map_err(|e| e.to_string())?;
    let (png, width, height) = super::render(text)?;
    unsafe { fs_write(args.output.as_ptr(), args.output.len(), png.as_ptr(), png.len()); }
    serde_json::to_vec(&serde_json::json!({"output":args.output,"width":width,"height":height,"size_bytes":png.len()})).map_err(|e|e.to_string())
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn invoke(_np: *const u8, _nl: usize, ap: *const u8, al: usize) {
    let result = run(unsafe { std::slice::from_raw_parts(ap, al) });
    let (bytes, error) = match result { Ok(bytes) => (bytes, 0), Err(e) => (e.into_bytes(), 1) };
    unsafe { set_invoke_result(bytes.as_ptr(), bytes.len(), error); }
}