[{"data":1,"prerenderedAt":28},["ShallowReactive",2],{"doc:en:plugins\u002Fquick-start":3},{"title":4,"lede":5,"html":6,"headings":7},"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.","\u003Ch2 id=\"what-you-need\">What you need\u003C\u002Fh2>\n\u003Cul>\n\u003Cli>Escouade, which ships the \u003Ccode class=\"key\">escouade-plugin\u003C\u002Fcode> command beside its program (see \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Ftesting\u002F\">testing a plugin\u003C\u002Fa>);\u003C\u002Fli>\n\u003Cli>for Discord, a channel and the URL of one of its webhooks (the channel’s settings › Integrations › Webhooks);\u003C\u002Fli>\n\u003Cli>for a program, Node 22.18 or later in TypeScript, Rust in Rust, to write and test the plugin. Whoever installs it needs neither.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2 id=\"a-discord-webhook-without-code\">A Discord webhook, without code\u003C\u002Fh2>\n\u003Cp>\u003Cstrong>1. The manifest.\u003C\u002Fstrong> Make a \u003Ccode class=\"key\">my-discord\u003C\u002Fcode> folder and copy into it the manifest of the Discord plugin, one of the \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Fexamples\u002F#discord\">example plugins\u003C\u002Fa>. Change its \u003Ccode class=\"key\">id\u003C\u002Fcode> to one of your own (\u003Ccode>io.github.&lt;your-login&gt;.discord\u003C\u002Fcode>): \u003Ccode class=\"key\">dev.escouade.*\u003C\u002Fcode> is kept for Escouade’s own plugins. The heart of the plugin is here:\u003C\u002Fp>\n\u003Cp>\u003Ccode>plugins\u002Fdiscord\u002Fescouade-plugin.json\u003C\u002Fcode> (excerpt)\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">  &quot;permissions&quot;: {\n    &quot;events&quot;: [&quot;ticket.column&quot;, &quot;agent.status&quot;],\n    &quot;network&quot;: [&quot;discord.com&quot;],\n    &quot;secrets&quot;: [&quot;webhook&quot;]\n  },\n  &quot;contributes&quot;: {\n    &quot;secrets&quot;: [{ &quot;key&quot;: &quot;webhook&quot;, &quot;title&quot;: { &quot;fr&quot;: &quot;Adresse du webhook Discord&quot;, &quot;en&quot;: &quot;Discord webhook URL&quot; } }],\n    &quot;webhooks&quot;: [\n      {\n        &quot;url&quot;: &quot;{{secrets.webhook}}&quot;,\n        &quot;events&quot;: [&quot;ticket.column&quot;],\n        &quot;when&quot;: { &quot;data.to&quot;: &quot;review&quot; },\n        &quot;body&quot;: {\n          &quot;content&quot;: &quot;🧪 **{{ticket.key}}** to test: {{ticket.title}} ({{project.name}})&quot;,\n          &quot;allowed_mentions&quot;: { &quot;parse&quot;: [] }\n        }\n      },\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>It asks for two kinds of facts (\u003Ccode class=\"key\">permissions.events\u003C\u002Fcode>), one host (\u003Ccode class=\"key\">permissions.network\u003C\u002Fcode>) 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 \u003Ccode class=\"key\">review\u003C\u002Fcode> column, “To review”.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>2. Check it.\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sh\">escouade-plugin validate my-discord\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>An error is told field by field, with its path (“\u003Ccode class=\"key\">escouade.min\u003C\u002Fcode> is missing.”).\u003C\u002Fp>\n\u003Cp>\u003Cstrong>3. Load it.\u003C\u002Fstrong> 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 \u003Ccode class=\"key\">discord.com\u003C\u002Fcode>, keep a secret. It is read again whenever one of its files changes.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>4. Its secret.\u003C\u002Fstrong> On its page, the “Settings” tab: paste the webhook’s URL. It goes to the system’s keychain.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>5. Try it.\u003C\u002Fstrong> 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.\u003C\u002Fp>\n\u003Ch2 id=\"a-program-in-typescript-or-in-rust\">A program, in TypeScript or in Rust\u003C\u002Fh2>\n\u003Cp>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:\u003C\u002Fp>\n\u003Cdiv class=\"table\">\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>\u003C\u002Fth>\n\u003Cth>TypeScript\u003C\u002Fth>\n\u003Cth>Rust\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\u003Ctr>\n\u003Ctd>You need\u003C\u002Ftd>\n\u003Ctd>Node 22.18\u003C\u002Ftd>\n\u003Ctd>Rust, with \u003Ccode>rustup target add wasm32-wasip1\u003C\u002Fcode>, and a C compiler for \u003Ccode>cargo test\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Create it\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>npm create escouade-plugin@latest my-plugin -- --kind program\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>npm create escouade-plugin@latest my-plugin -- --kind rust\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>The manifest says\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>&quot;run&quot;: { &quot;js&quot;: &quot;dist\u002Fplugin.mjs&quot; }\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>&quot;run&quot;: { &quot;wasm&quot;: &quot;Cargo.toml&quot; }\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Its tests\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>npm test\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>cargo test\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Check its manifest\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>npm run validate\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>escouade-plugin validate .\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>The bench\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>npm run bench\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>escouade-plugin test .\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>The module\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>npm run build\u003C\u002Fcode>, Escouade compiles the rest\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>escouade-plugin build .\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>What you commit\u003C\u002Ftd>\n\u003Ctd>\u003Ccode class=\"key\">dist\u002Fplugin.mjs\u003C\u002Fcode> and its sources\u003C\u002Ftd>\n\u003Ctd>\u003Ccode class=\"key\">Cargo.lock\u003C\u002Fcode> and its sources\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\u003C\u002Ftable>\u003C\u002Fdiv>\n\u003Cp>Pick TypeScript to go fast. Pick Rust for an API client that already exists, for work on bytes, or for \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Frust\u002F\">the kit\u003C\u002Fa>, which writes a source of the Kanban or a code host for you.\u003C\u002Fp>\n\u003Ch3 id=\"in-typescript\">In TypeScript\u003C\u002Fh3>\n\u003Cp>\u003Cstrong>1. The template.\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sh\">npm create escouade-plugin@latest my-plugin -- --kind program\ncd my-plugin\nnpm install\nnpm test\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The template says when a ticket is ready to test and refuses force pushes; its code and its tests are in \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Fsdk\u002F#the-client\">the SDK\u003C\u002Fa>. Its manifest names \u003Ccode>&quot;run&quot;: { &quot;js&quot;: &quot;dist\u002Fplugin.mjs&quot; }\u003C\u002Fcode>: Escouade runs it inside the app, in WASM, with no Node for whoever installs it.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>2. Send a message.\u003C\u002Fstrong> 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 \u003Ccode class=\"key\">fetch\u003C\u002Fcode>, which goes through Escouade, to the hosts of \u003Ccode class=\"key\">permissions.network\u003C\u002Fcode> only:\u003C\u002Fp>\n\u003Cp>\u003Ccode>plugins\u002Fslack\u002Fsrc\u002Fplugin.ts\u003C\u002Fcode> (excerpt)\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-ts\">  return createPlugin().onEvent([&#39;ticket.column&#39;, &#39;agent.status&#39;], async (fact, p) =&gt; {\n    const text = textOf(fact);\n    if (!text || !fact.project) return;\n    const token = await p.secret(&#39;token&#39;);\n    if (!token) return p.log.warn(&#39;no Slack token kept: nothing sent (Plugins › Slack › Settings)&#39;);\n    \u002F\u002F A bot token, never anything else, goes to Slack (and never into the journal).\n    if (!token.startsWith(&#39;xoxb-&#39;)) return p.log.warn(&#39;the token kept is not a Slack bot token (xoxb-…): nothing sent&#39;);\n    const channel = channelOf(fact.project.name, channels);\n    try {\n      const res = await fetch(&#39;https:\u002F\u002Fslack.com\u002Fapi\u002Fchat.postMessage&#39;, {\n        method: &#39;POST&#39;,\n        headers: { authorization: `Bearer ${token}`, &#39;content-type&#39;: &#39;application\u002Fjson; charset=utf-8&#39; },\n        body: JSON.stringify({ channel, text }),\n      });\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Add to your manifest what your code asks for: the fact in \u003Ccode class=\"key\">permissions.events\u003C\u002Fcode>, the host in \u003Ccode class=\"key\">permissions.network\u003C\u002Fcode>, the secret in \u003Ccode class=\"key\">permissions.secrets\u003C\u002Fcode> and \u003Ccode class=\"key\">contributes.secrets\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>3. The bench.\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sh\">npm run bench\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Ftesting\u002F\">conformance bench\u003C\u002Fa> plays your program as Escouade does: the handshake, the facts, the decisions, the delays, the shutdown. It must pass before any publishing.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>4. Load it\u003C\u002Fstrong>: \u003Ccode>npm run build\u003C\u002Fcode> bundles your code into \u003Ccode class=\"key\">dist\u002Fplugin.mjs\u003C\u002Fcode>, then load the folder like the first one, from “In development”. Its journal shows its lines live.\u003C\u002Fp>\n\u003Ch3 id=\"in-rust\">In Rust\u003C\u002Fh3>\n\u003Cp>\u003Cstrong>1. The template.\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sh\">npm create escouade-plugin@latest my-plugin -- --kind rust\ncd my-plugin\ncargo test\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode class=\"key\">npm\u003C\u002Fcode> only writes the folder: the plugin has no \u003Ccode class=\"key\">package.json\u003C\u002Fcode> and no \u003Ccode class=\"key\">node_modules\u003C\u002Fcode>. It depends on \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Frust\u002F\">the kit\u003C\u002Fa> by version, with its \u003Ccode class=\"key\">Cargo.lock\u003C\u002Fcode>, and its code fits in one file:\n\u003Ccode>sdk\u002Fcreate-escouade-plugin\u002Ftemplates\u002Frust\u002Fsrc\u002Flib.rs\u003C\u002Fcode> (excerpt)\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">struct Template;\n\nimpl Plugin for Template {\n    \u002F\u002F Every method of Escouade comes here but `initialize` and `shutdown`, which the kit answers. A notification too: what\n    \u002F\u002F it returns goes nowhere. Nothing may block here: an `.await` gives the turn back to Escouade.\n    fn call&lt;&#39;a&gt;(\n        &amp;&#39;a self,\n        cx: &amp;&#39;a Cx,\n        method: &amp;&#39;a str,\n        params: Value,\n    ) -&gt; BoxFuture&lt;&#39;a, Result&lt;Value, Refusal&gt;&gt; {\n        Box::pin(async move {\n            match method {\n                &quot;events\u002Ffact&quot; =&gt; {\n                    fact(cx, &amp;params);\n                    Ok(Value::Null)\n                }\n                &quot;permissions\u002Fdecide&quot; =&gt; Ok(decide(&amp;params)),\n                other =&gt; Err(Refusal::not_found(other)),\n            }\n        })\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>What each call of the host does is in \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Fcalls\u002F\">the calls\u003C\u002Fa>, in TypeScript and in Rust.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>2. The bench.\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sh\">escouade-plugin validate .\nescouade-plugin test .\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode class=\"key\">escouade-plugin\u003C\u002Fcode> 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.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>3. Load it.\u003C\u002Fstrong> \u003Ccode>escouade-plugin build .\u003C\u002Fcode> writes the module into \u003Ccode class=\"key\">packs\u002F\u003C\u002Fcode>: copy it beside the manifest as \u003Ccode class=\"key\">plugin.wasm\u003C\u002Fcode>, 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.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sh\">escouade-plugin build .\ncp packs\u002F*.wasm plugin.wasm\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"next\">Next\u003C\u002Fh2>\n\u003Cul>\n\u003Cli>\u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Fmanifest\u002F\">The manifest\u003C\u002Fa>, field by field, and \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Fevents\u002F\">the events\u003C\u002Fa> a plugin can receive.\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Fcapabilities\u002F\">The host’s capabilities\u003C\u002Fa> and \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Fcalls\u002F\">the calls\u003C\u002Fa>, with an example in TS and in Rust for each.\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Fpermissions\u002F\">Permissions\u003C\u002Fa>: what each one opens.\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Fpublishing\u002F\">Publishing on the store\u003C\u002Fa>, when it is ready.\u003C\u002Fli>\n\u003C\u002Ful>\n",[8,12,15,18,22,25],{"id":9,"text":10,"depth":11},"what-you-need","What you need",2,{"id":13,"text":14,"depth":11},"a-discord-webhook-without-code","A Discord webhook, without code",{"id":16,"text":17,"depth":11},"a-program-in-typescript-or-in-rust","A program, in TypeScript or in Rust",{"id":19,"text":20,"depth":21},"in-typescript","In TypeScript",3,{"id":23,"text":24,"depth":21},"in-rust","In Rust",{"id":26,"text":27,"depth":11},"next","Next",1793889382085]