↑ ↓ to choose, Enter to open, Esc to close

Plugins

A first plugin

A working plugin in ten minutes: first a Discord webhook, without a line of code, then a program in TypeScript or in Rust that reacts to the same events. Each step is checked before the next.

What you need

  • Escouade, which ships the escouade-plugin command beside its program (see testing a plugin);
  • for Discord, a channel and the URL of one of its webhooks (the channel’s settings › Integrations › Webhooks);
  • for a program, Node 22.18 or later in TypeScript, Rust in Rust, to write and test the plugin. Whoever installs it needs neither.

A Discord webhook, without code

1. The manifest. Make a my-discord folder and copy into it the manifest of the Discord plugin, one of the example plugins. Change its id to one of your own (io.github.<your-login>.discord): dev.escouade.* is kept for Escouade’s own plugins. The heart of the plugin is here:

plugins/discord/escouade-plugin.json (excerpt)

  "permissions": {
    "events": ["ticket.column", "agent.status"],
    "network": ["discord.com"],
    "secrets": ["webhook"]
  },
  "contributes": {
    "secrets": [{ "key": "webhook", "title": { "fr": "Adresse du webhook Discord", "en": "Discord webhook URL" } }],
    "webhooks": [
      {
        "url": "{{secrets.webhook}}",
        "events": ["ticket.column"],
        "when": { "data.to": "review" },
        "body": {
          "content": "🧪 **{{ticket.key}}** to test: {{ticket.title}} ({{project.name}})",
          "allowed_mentions": { "parse": [] }
        }
      },

It asks for two kinds of facts (permissions.events), one host (permissions.network) and one secret, the webhook’s URL, which it never sees written in a file. The webhook goes out when a ticket moves to the review column, “To review”.

2. Check it.

escouade-plugin validate my-discord

An error is told field by field, with its path (“escouade.min is missing.”).

3. Load it. In the Plugins view, “In development”, then “Load a folder…” and your folder. The window says what it will be able to do: get these events, connect to discord.com, keep a secret. It is read again whenever one of its files changes.

4. Its secret. On its page, the “Settings” tab: paste the webhook’s URL. It goes to the system’s keychain.

5. Try it. Move a ticket to “To review”: the message reaches the channel. The “Journal” tab of its page notes each call, by its host and its answer, never the whole URL.

A program, in TypeScript or in Rust

Both give a WASM module that Escouade runs inside the app, on the same methods. The template of each language says when a ticket is ready to test and refuses force pushes:

TypeScript Rust
You need Node 22.18 Rust, with rustup target add wasm32-wasip1, and a C compiler for cargo test
Create it npm create escouade-plugin@latest my-plugin -- --kind program npm create escouade-plugin@latest my-plugin -- --kind rust
The manifest says "run": { "js": "dist/plugin.mjs" } "run": { "wasm": "Cargo.toml" }
Its tests npm test cargo test
Check its manifest npm run validate escouade-plugin validate .
The bench npm run bench escouade-plugin test .
The module npm run build, Escouade compiles the rest escouade-plugin build .
What you commit dist/plugin.mjs and its sources Cargo.lock and its sources

Pick TypeScript to go fast. Pick Rust for an API client that already exists, for work on bytes, or for the kit, which writes a source of the Kanban or a code host for you.

In TypeScript

1. The template.

npm create escouade-plugin@latest my-plugin -- --kind program
cd my-plugin
npm install
npm test

The template says when a ticket is ready to test and refuses force pushes; its code and its tests are in the SDK. Its manifest names "run": { "js": "dist/plugin.mjs" }: Escouade runs it inside the app, in WASM, with no Node for whoever installs it.

2. Send a message. A program can do more than a webhook: pick the channel, format the text, keep a state. The Slack plugin, for example, asks for its secret on each fact and posts with fetch, which goes through Escouade, to the hosts of permissions.network only:

plugins/slack/src/plugin.ts (excerpt)

  return createPlugin().onEvent(['ticket.column', 'agent.status'], async (fact, p) => {
    const text = textOf(fact);
    if (!text || !fact.project) return;
    const token = await p.secret('token');
    if (!token) return p.log.warn('no Slack token kept: nothing sent (Plugins › Slack › Settings)');
    // A bot token, never anything else, goes to Slack (and never into the journal).
    if (!token.startsWith('xoxb-')) return p.log.warn('the token kept is not a Slack bot token (xoxb-…): nothing sent');
    const channel = channelOf(fact.project.name, channels);
    try {
      const res = await fetch('https://slack.com/api/chat.postMessage', {
        method: 'POST',
        headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json; charset=utf-8' },
        body: JSON.stringify({ channel, text }),
      });

Add to your manifest what your code asks for: the fact in permissions.events, the host in permissions.network, the secret in permissions.secrets and contributes.secrets.

3. The bench.

npm run bench

The conformance bench plays your program as Escouade does: the handshake, the facts, the decisions, the delays, the shutdown. It must pass before any publishing.

4. Load it: npm run build bundles your code into dist/plugin.mjs, then load the folder like the first one, from “In development”. Its journal shows its lines live.

In Rust

1. The template.

npm create escouade-plugin@latest my-plugin -- --kind rust
cd my-plugin
cargo test

npm only writes the folder: the plugin has no package.json and no node_modules. It depends on the kit by version, with its Cargo.lock, and its code fits in one file: sdk/create-escouade-plugin/templates/rust/src/lib.rs (excerpt)

struct Template;

impl Plugin for Template {
    // Every method of Escouade comes here but `initialize` and `shutdown`, which the kit answers. A notification too: what
    // it returns goes nowhere. Nothing may block here: an `.await` gives the turn back to Escouade.
    fn call<'a>(
        &'a self,
        cx: &'a Cx,
        method: &'a str,
        params: Value,
    ) -> BoxFuture<'a, Result<Value, Refusal>> {
        Box::pin(async move {
            match method {
                "events/fact" => {
                    fact(cx, &params);
                    Ok(Value::Null)
                }
                "permissions/decide" => Ok(decide(&params)),
                other => Err(Refusal::not_found(other)),
            }
        })
    }
}

What each call of the host does is in the calls, in TypeScript and in Rust.

2. The bench.

escouade-plugin validate .
escouade-plugin test .

escouade-plugin compiles the module as the store does, with a compiler it downloads itself the first time (650 MB, a minute), then plays it as Escouade does.

3. Load it. escouade-plugin build . writes the module into packs/: copy it beside the manifest as plugin.wasm, which the template keeps out of git, then load the folder from “In development”. Copy it again after each change: the store, for its part, compiles the sources.

escouade-plugin build .
cp packs/*.wasm plugin.wasm

Next