Skip to content

Write an extension

An extension is a program the node starts and talks to over stdin and stdout. The 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.

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.

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.

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"
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.

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, or with a tool of its own if you add a [[tools]] table.

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

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");
}
Terminal window
cargo test

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

Terminal window
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.

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

Terminal window
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);
  • 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.