Plugins
Events
Escouade tells what happens in it as facts: an agent created, a turn over, a ticket to test… A plugin subscribes to the facts it wants: its program receives them, a declared webhook sends them to an address without code, or a declared command runs.
Facts are told by Escouade’s core where the state changes, never read back from what the window shows: they do not change when the interface does.
A fact
Every fact has the same shape, in JSON:
{
"version": 1,
"seq": 42,
"at": "2026-10-29T14:03:12.511Z",
"name": "ticket.column",
"data": { "from": "doing", "to": "review" },
"project": { "id": "8c1f…", "name": "Escouade", "private": false },
"ticket": { "id": "e02a…", "key": "ESC-177", "title": "Events" },
"agent": { "id": "5b7d…", "name": "esc-177-events" }
}
version: the version of the facts’ shape. A field added keeps it; a field removed or changed in meaning moves to the next one.seq: its number since Escouade started. Two facts come in the order of their numbers.nameanddata: what happened, and what it says of it (the table below).project,ticket,agent: what it is about, by their ids and their names, when it is about them. The fact of an agent working on a ticket also says its ticket; the project says whether it is private (private).
A fact never carries the conversation: a plugin learns that a turn is over and how, not what was said in it.
The facts
| Fact | When | data |
|---|---|---|
agent.created |
An agent is created (by hand, for a ticket, by a client of the MCP server). | harness, model |
agent.renamed |
It is named (after its first message, or by hand); the facts after it say its new name. | from, to |
agent.status |
Its status changes. | status and was: idle, running, waiting (a request waits for an answer), done, error |
agent.stopped |
It is archived (removed: false) or deleted (removed: true). |
removed |
agent.handover |
It goes on with another harness. | from, to |
turn.ended |
A turn ends. | end: finished, interrupted, limited (the usage limit), signIn (a sign-in to do again) or error, with error (its first line) |
request.permission |
A permission waits. | requestId, tool, arg (what the tool acts on, summed up as on the card) |
request.question |
A question waits. | requestId, questions |
request.answered |
A request has its answer. | requestId, kind (permission or question), decision (allow, always, deny), by (the client of the MCP server that answered, else absent) |
ticket.created |
A ticket is created. | column |
ticket.column |
It moves to another column. | from, to: todo, doing, review, done |
ticket.report |
Its agent reports on its criteria. | met, total |
ticket.approved |
Its validation starts. | |
ticket.rejected |
It is sent back to its agent. | comment |
ticket.merged |
It is merged into its target branch. | target, commit |
ticket.conflict |
Its merge meets a conflict. | target, files |
pr.opened |
A pull request is opened for it. | url, target |
tests.result |
The tests of its validation passed or not. | passed |
project.added |
A project is opened. | path |
project.closed |
It is closed. | |
worktree.created |
A worktree is created for an agent. | branch, path |
worktree.removed |
It is removed. | branch, path |
launch.started |
A test launch of an agent starts. | run (its name) |
launch.ready |
It answers. | url |
launch.stopped |
It stops. | |
docs.written |
A page of the docs is written. | page |
docs.filed |
A ticket’s copy of the docs is poured on the docs’ branch, at its validation. | pages |
docs.gaps |
A page is compared to the code. | page, gaps (how many gaps) |
memory.written |
A memory of the project’s memory is written. | memory, new |
memory.forgotten |
It is forgotten. | memory |
decision.proposed |
A decision is proposed or revised. | id (no number yet), title |
decision.accepted |
A human accepts it. | number (given then), id, title |
account.switched |
An agent changes account. | from, to |
workflow.step_entered |
An agent enters a step of its project’s workflow. | workflow, step, door (the command typed, if any), pass (its nth stay there) |
workflow.step_left |
It leaves it: duration, cost, outcome. | workflow, step, durationMs, cost, outcome (done, back, failed, stopped, left) |
workflow.looped_back |
It goes back to a step already passed. | workflow, from, to, reason (the line the step ended on) |
workflow.left |
It leaves the workflow: it does something else. | workflow, doing |
workflow.changed |
A new reading of the project’s workflows came out different. | workflows (the ids of those that changed, appeared or went) |
app.started |
Escouade starts. | version |
Subscribing
A plugin only receives the facts it asks for, in permissions.events of its manifest. A name ending with * takes them all (ticket.*, or * for everything):
"permissions": { "events": ["ticket.column", "agent.status"] }
The install window shows them: “Receives these events: …”.
And only those of the projects it is turned on for. A plugin is on for every project, except private projects: a private project’s fact only goes to a plugin turned on for it, one by one, in Settings › Plugins. A fact of no project (app.started) goes to every plugin on for the app. An events:… permission taken back in the Permissions tab of its page cuts those facts, for its program as for its webhooks and its commands.
In a program
A plugin’s program (run) receives each fact by the events/fact notification, the whole fact as its parameters. It does not answer: Escouade never waits for a plugin.
{
"jsonrpc": "2.0",
"method": "events/fact",
"params": {
"version": 1,
"seq": 42,
"name": "ticket.column",
"data": { "from": "doing", "to": "review" },
"ticket": { "id": "e02a…", "key": "ESC-177", "title": "Events" }
}
}
To start it at the first fact, put it in activation: "activation": ["event:ticket.column"].
A plugin’s facts go through a queue of its own, of 256 facts. The next one goes once its program took the last one on its input; a slow plugin holds nothing else back. When the queue is full, the oldest fact is lost, and its journal says so: “the queue of facts is full (256): the oldest, ticket.column #41, is dropped”.
A webhook, without code
A declarative plugin (without run) calls an address on a fact. Each webhook of contributes.webhooks says:
| Field | Required | What it says |
|---|---|---|
url |
yes | The address, http:// or https://, to a host of permissions.network. It may come from a secret of the plugin: {{secrets.webhook}}. |
events |
yes | The facts that call it (ticket.* takes them all). They must also be in permissions.events. |
when |
no | What the fact must say, path by path: { "data.to": "review" }, or a list of values: { "data.status": ["waiting", "error"] }. |
body |
no | The body’s template: a JSON object, or a text. Without it, the whole fact in JSON. |
contentType |
no | The body’s type: application/json for an object or without a template, text/plain for a text. |
headers |
no | Headers, which may take a secret: { "Authorization": "Bearer {{secrets.token}}" }. |
A template replaces {{path}} with the fact’s value: {{name}}, {{ticket.key}}, {{ticket.title}}, {{project.name}}, {{agent.name}}, {{data.to}}… In an object, a text that is only a {{path}} takes the value as it is (a list stays a list); in a JSON text, the values are escaped. A secret never goes in the body.
The Discord plugin, whole: one webhook for a ticket moved to “To review”, another for an agent waiting.
plugins/discord/escouade-plugin.json
{
"$schema": "https://escouade.dev/schemas/plugin-1.json",
"id": "dev.escouade.discord",
"name": "Discord",
"version": "1.1.0",
"description": {
"fr": "Les tickets à tester et les agents qui attendent, dans un salon Discord : un webhook, sans code.",
"en": "Tickets to test and agents waiting, in a Discord channel: a webhook, no code."
},
"author": { "name": "Escouade", "url": "https://escouade.dev" },
"license": "MIT",
"repository": "https://github.com/guillaume-gagnaire/escouade/tree/main/plugins/discord",
"escouade": { "api": "1", "min": "2.1.0" },
"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": [] }
}
},
{
"url": "{{secrets.webhook}}",
"events": ["agent.status"],
"when": { "data.status": "waiting" },
"body": {
"content": "✋ **{{agent.name}}** waits for you ({{project.name}})",
"allowed_mentions": { "parse": [] }
}
}
]
}
}
A webhook only goes to a host of permissions.network, the one the install window shows, whether its address is written whole, taken from a secret or made of a value of the fact. Escouade refuses at load a manifest whose webhook written whole goes elsewhere, and makes no call whose address, once made, goes elsewhere (the journal says so): a ticket title like evil.dev/x sends nothing. A host is compared without regard to case, without the address’s user or port. hooks.slack.com covers itself alone; *.example.dev covers its subdomains at any depth (hooks.example.dev, a.b.example.dev), but not example.dev itself, to declare apart, nor evilexample.dev. The webhook has 10 s to answer, without redirects. Its journal notes each call by its host and its answer, never the whole address (Discord’s holds a token).
A command on a fact
contributes.commands runs a command line on a fact, in the user’s shell, in the folder of the fact’s project:
"contributes": {
"commands": [
{ "id": "turn-over", "events": ["turn.ended"], "when": { "data.end": "finished" }, "run": "say \"Claude is done\"" }
]
}
- Nothing runs before the user agrees, as with the commands of
.escouade.yml: the Permissions tab of its page, in Settings › Plugins, shows them under “Runs these commands” with “Agree to these commands”, and the agreement holds for them as they are; “Take the agreement back” stops them. A command, a fact or a condition that changes, and it takes agreeing again. The journal tells each command refused for want of agreement. - The fact never goes in the command line: a ticket’s title runs nothing. The command finds it in its variables:
ESCOUADE_FACT(the whole fact in JSON),ESCOUADE_FACT_NAME,ESCOUADE_PROJECT,ESCOUADE_TICKET(its key) andESCOUADE_AGENT. - It has 60 s. Its journal notes its end (
exit 0) and, when it fails, its last lines.
The workflow reader
Escouade reads the workflows of a project by itself (GSD, Superpowers, Ralph…). A plugin can teach it one it does not know, with contributes.workflowReaders:
"contributes": {
"workflowReaders": [
{ "id": "bmad", "name": "BMAD", "files": ["_bmad/**/*.md", "docs/*.md"] }
]
}
- Escouade calls
workflow/readwith{ "reader": "bmad", "root": "…", "repos": [], "files": [{ "path": "_bmad/brief.md", "text": "…" }] }: only the files its globs match, from the project’s folder (and from each repository’s of a project of several), as text, at most 200 files of 256 KB. A glob with..or an absolute path finds nothing. - The plugin answers the graph:
{ "workflows": [{ "id", "name", "steps": [{ "name", "command", "description", "path", "agents", "reads", "writes" }], "transitions": [{ "from", "to", "back", "condition", "max" }], "exit" }] }. A step is signalled by itscommandtyped, by the sub-agents it names or the files it writes; a transition withbackis a way back, with itsconditionas the reason when the step says none. - The workflow is followed like those Escouade reads: the Workflow tab, the card, the
workflow.*facts. Its id isplugin:<plugin>:<id>, so it never takes the place of another. - A graph that does not hold (a step without a name, two of one name, a transition to a step that is not there, more than 50 workflows) is refused whole, with why, in the journal. The call has 60 s.
The journal
Each plugin’s journal (Settings › Plugins, and plugin-logs/<id>.log in Escouade’s data) tells the webhooks called and their answers, the commands run and how they ended, what was refused (a secret not its own, an address not declared, a command not agreed to) and the facts lost from a full queue.
Trying it
escouade-plugin dev <folder> runs your plugin outside Escouade and sends it the facts you pick; escouade-plugin test <folder> checks that it takes a fact without crashing. See Testing a plugin.