# Write an extension

> Build a compiled extension in Rust on the pointman-extension crate, test it against a stand-in node, try it on a machine, and release it so nodes can install it.

An extension is a program the node starts and talks to over stdin and stdout. The
[pointman-extension](https://github.com/pointman-ai/pointman-extension) crate speaks that protocol for
you, so your code only declares commands and answers them. New extensions are compiled: one small
binary per platform, with nothing to install on the machine.

## Start from the template

[pointman-extension-template](https://github.com/pointman-ai/pointman-extension-template) is a
working extension to start from: one command, its tests, CI, and a release workflow that builds every
platform. On GitHub, **Use this template** makes a copy. Name it `pointman-<id>`, rename `hello`
everywhere (the package, the id in `extension.toml`, and the command names), and write your own
commands. The rest of this page walks through what's in it.

## The folder

```text
pointman-greeter/
  extension.toml     # what it is and what it adds
  Cargo.toml
  src/main.rs        # its commands
  tests/greeter.rs   # its tests, against a stand-in node
```

Name the repo `pointman-<id>`, with the topic `pointman-extension`. Extension repos are MIT by
default.

```toml
# extension.toml
[extension]
id = "greeter"
name = "Greeter"
version = "0.1.0"
publisher = "acme"
summary = "Writes a greeting to a file."
platforms = ["macos-arm64", "macos-x86_64", "linux-x86_64", "linux-arm64"]

[run]
command = ["bin/greeter"]      # the release build, in the package's bin/
runtime = "binary"

[[commands]]
kind = "greeter.greet"
title = "Greet"
params = { type = "object", required = ["name"], properties = { name = { type = "string" } } }
lane = "main"                  # commands in a lane run one at a time

[settings]
type = "object"

[settings.properties.greeting]
type = "string"
default = "Hello"
```

```toml
# Cargo.toml
[package]
name = "greeter"
version = "0.1.0"
edition = "2021"
license = "MIT"
publish = false

[dependencies]
pointman-extension = { git = "https://github.com/pointman-ai/pointman-extension", tag = "v0.1.0" }
anyhow = "1"
serde_json = "1"
```

Every table and field is in the [`extension.toml` reference](/reference/extension-manifest/).

## A command

```rust
// src/main.rs
use anyhow::Context;
use pointman_extension::{Extension, Job};
use serde_json::json;

fn main() -> anyhow::Result<()> {
    let mut ext = Extension::new()?;
    let h = ext.handle();

    ext.command("greeter.greet", move |job: &Job| {
        let name = job.params["name"].as_str().context("name is missing")?;
        let greeting = h.settings()["greeting"].as_str().context("no greeting setting")?.to_string();
        let text = format!("{greeting}, {name}!");
        job.progress(0.5, "writing");
        let out = job.work.join("greeting.txt");
        std::fs::write(&out, format!("{text}\n"))?;
        job.output(&out, "text", "main", json!({}));   // posted where the request came from
        Ok(json!({ "text": text }))
    });

    ext.run()
}
```

- **`job.params`** are the command's parameters, already checked against its `params` schema.
- **`job.progress`** shows how far along it is, in the thread and in `request_status`.
- **`job.output`** posts a file it made, in the thread that asked.
- **`h.settings()`** is this machine's settings for it, with the defaults from `[settings]`.
- **Log with `h.log(…)`,** never by printing: stdout is the node's.
- An error ends the request as failed, with its message.

Agents ask for it with [`run_on_machine`](/reference/mcp-tools/#run_on_machine), or with a tool of its
own if you add a `[[tools]]` table.

## Test it

The crate's stand-in node starts your built binary the way a real node does:

```rust
// tests/greeter.rs
use pointman_extension::testing::StandIn;
use serde_json::json;

fn greeter() -> StandIn {
    StandIn::new(env!("CARGO_MANIFEST_DIR")).binary(env!("CARGO_BIN_EXE_greeter"))
}

#[test]
fn greet_writes_the_greeting() {
    let node = greeter().settings(json!({ "greeting": "Hi" })).start().unwrap();
    let done = node.run("greeter.greet", json!({ "name": "Sam" })).unwrap();
    assert_eq!(done.result, json!({ "text": "Hi, Sam!" }));
}

#[test]
fn a_missing_name_fails() {
    let node = greeter().start().unwrap();
    assert_eq!(node.run("greeter.greet", json!({})).unwrap_err().message, "name is missing");
}
```

```bash
cargo test
```

The stand-in also answers vault secrets, within what `[permissions]` allows.

## Try it on a machine

```bash
cargo build --release
mkdir -p bin && cp target/release/greeter bin/greeter
pointman add .
```

`pointman add` with a folder installs it on this machine's node as it is, and turns it on. Run it
from any thread, then `pointman add .` again after each change.

## Release it

Bump `version` in `extension.toml`, then push a tag with that version:

```bash
git tag v0.1.1 && git push origin v0.1.1
```

A release carries one build per platform in `platforms`, as assets named
`<id>-<version>-<platform>.tar.gz`, each with `extension.toml`, `bin/<id>` and the rest of the
package's files at its top. The template's release workflow does this when you push the tag: it
builds each platform on its own runner, Linux with musl so the binary runs on any distribution, and
publishes the release once every asset is attached.

When the release is published, your core takes it into your workspace's catalogue, and machines
install it with `pointman add greeter` and move to new versions with `pointman upgrade`. That needs:

- the board's GitHub App installed on the repo's owner (see [Projects](/guides/projects/#connect-github));
- the repo's owner among the GitHub accounts your core takes extensions from. That's `pointman-ai`
  unless your core sets `POINTMAN_EXTENSION_OWNERS`, a comma list, where `*` means any account the
  App is installed on.

Core checks each build, refuses a release missing one, and keeps every version. Each node fetches its
own platform's build and checks its hash.

A public repo's release is also listed in Pointman's marketplace, where anyone can find it: see
[Publish an extension](/guides/publish-an-extension/).

:::tip
An extension that wraps a Python app's own API, as Blender's and DaVinci Resolve's do, can be
written in Python instead, with `runtime = "python3.12"`.
:::
