Fixture mod: exercises the tool.call deny path, .catch fallback and pass-through.

<img src="doc_en/banner.svg" alt="OpenSquad" width="720" />
<strong>English</strong> | <a href="README_ZH.md">中文</a>
OpenSquad is a local-first multi-agent collaboration framework. Multiple autonomous agents (PM, Coder, QA, etc.) communicate via group chat to coordinate and complete complex tasks.
<img src="doc_en/screenshots/agent_web.png" alt="OpenSquad single-agent web workspace" width="900" />
<img src="doc_shared/screenshots/chat_group.png" alt="OpenSquad group chat" width="900" />
OpenSquad enables multiple AI agents to collaborate like a real team. Each agent runs as an independent process with its own LLM connection, tool set, and memory. Agents communicate through group chat, coordinated by a PM agent, to tackle complex tasks that a single agent cannot handle alone.
A single AI agent has clear limitations when dealing with complex projects:
OpenSquad solves these through multi-agent collaboration: PM handles task decomposition and coordination, Coder focuses on implementation, QA independently verifies — each with a clear responsibility.
flowchart TB
User["User / Web UI"] --> GW["Gateway :9555"]
GW --> LA["Launcher :9600"]
LA --> PM["PM Agent"]
LA --> Dev["Coder Agent"]
LA --> QA["QA Agent"]
PM & Dev & QA --> IM["Group chat / IM"]
PM & Dev & QA --> Plugins["Plugins / MCP / Skills"]
Plugins --> Svc["Service plugins e.g. websearch :9001"]
Details: Architecture · Documentation hub
uv sync (or install.bat / install.sh).api_key in src/model_cards/ (see model cards guide).uv run opensquad init (creates ~/.opensquad/workspace by default).uv run opensquad start.http://127.0.0.1:5173 (or Gateway port from system_config).module.register, and transpiles their .ts / .tsx with a vendored sucrase — so no Node-version-dependent type stripping)Windows
git clone https://github.com/opensquad-ai/opensquad.git && cd opensquad && install.bat
Linux / macOS
git clone https://github.com/opensquad-ai/opensquad.git && cd opensquad && bash install.sh
The script automatically: checks prerequisites → installs dependencies → initializes workspace → starts all services.
Note: After first start, add your LLM API key to a model card under
src/model_cards/.
uv is a fast Python package manager. Recommended.
# Install uv (if not already)
pip install uv
# Clone the project
git clone https://github.com/opensquad-ai/opensquad.git
cd opensquad
# Install dependencies (uses uv.lock for reproducible builds)
uv sync
# Install frontend dependencies
cd src/opensquad/gateway/nexuschat-pro && npm install && cd ../../..
# Initialize and start
uv run opensquad init
uv run opensquad start
From PyPI — runtime, built web UI and the default resources:
pip install opensquad
opensquad init
opensquad start
Since 0.8.48 the wheel is a complete deployment. It ships the default
model_cards/,plugins/,agents/,pymcp/,collab_cards/,role_cards/andskills/next to theopensquadpackage, soopensquad initseeds a runnable workspace andopensquad startfinds every service. One gap remains: a fresh pip workspace has nosystem_config.json(the wheel carries no template), so ports stay on their defaults — gateway 9555, launcher 9600, registry 9720, frontend 5173 — until you save one from the Web UI's Settings.
From a checkout — full deployment, editable install:
git clone https://github.com/opensquad-ai/opensquad.git
cd opensquad
pip install -e .
# Install frontend dependencies
cd src/opensquad/gateway/nexuschat-pro && npm install && cd ../../..
# Initialize and start
opensquad init
opensquad start
The npm package is a thin bootstrap: on first run it installs the matching Python opensquad CLI, then forwards every command to it.
npm install -g opensquad-ai
opensquad init
opensquad start
Requires Node.js 18+ (Mods host: 20.6+) and Python 3.11+. npx opensquad-ai works too, with no global install.
git clone https://github.com/opensquad-ai/opensquad.git
cd opensquad
# Start (auto-builds image)
docker compose up -d
# View logs
docker compose logs -f
After start, visit http://localhost:9555. Data is persisted in Docker volumes.
Custom configuration:
# Edit config
cp src/system_config.example.json src/system_config.json
# Fill in your LLM API Key...
# Mount config and start
docker run -d \
-p 9555:9555 -p 9600:9600 -p 9720:9720 \
-v opensquad-data:/data \
-v ./src/system_config.json:/app/src/system_config.json:ro \
opensquad
Use the row that matches how you installed OpenSquad:
| Installed via | Update with | Notes |
|---|---|---|
| Desktop app (installer) | In-app Check for updates, or opensquad update | Fully automatic: it downloads the installer, installs it silently and relaunches the app. Builds older than v0.8.43 must download the installer once by hand — the relaunch-on-update logic shipped in that version. |
pip install opensquad | pip install --upgrade opensquad | |
npm install -g opensquad-ai | npm install -g opensquad-ai@latest | The wrapper re-installs the matching Python package when the versions differ. |
git clone (uv sync / pip install -e .) | git pull, then re-run the same install command | Re-run npm install under src/opensquad/gateway/nexuschat-pro if the frontend changed; npm run build there to serve the UI statically instead of through Vite. |
| Docker | git pull && docker compose up -d --build | The image is built from the local Dockerfile, not pulled from a registry. |
opensquad update only installs anything for the packaged desktop app. It cannot rewrite a pip / npm / source install, so on those it prints the command from the table and exits — without touching your environment or your git state.
opensquad start launches all 4 services:
| Service | Port | Description |
|---|---|---|
| Gateway Backend | 9555 | FastAPI backend (WebSocket + HTTP) |
| Plugin Registry | 9720 | Plugin store API |
| Frontend Dev | 5173 | Vite React frontend |
| Launcher | 9600 | Agent process manager |
Open http://127.0.0.1:5173 in your browser to create and configure agents via the Web UI.
| Command | Description |
|---|---|
opensquad init [--workspace <path>] | Initialize workspace (default: ~/.opensquad/workspace) |
opensquad start [--port <port>] | Start all services |
opensquad stop | Stop all OpenSquad services (frontend, gateway, launcher, adapters, agent processes) |
opensquad status | Show agent and service status |
opensquad plugin list | List installed plugins |
opensquad plugin install <id> | Install a plugin from the store or Git URL |
opensquad plugin uninstall <id> | Uninstall a plugin |
Run without installing:
python -m opensquad.cli start
Copy system_config.example.json to your workspace or src/system_config.json (see architecture-paths):
{
"hosts": { "gateway": "127.0.0.1" },
"ports": { "gateway": 9555, "launcher": 9600, "websearch": 9001 },
"auth": { "node_secret": "your-secret-here" }
}
Never commit real system_config.json, auth.json, or model cards with API keys.
LLM API keys live in model cards (src/model_cards/*.json). Agents are created via the Web UI at runtime.
Start here: Documentation hub → Getting started (EN)
| Document | Description |
|---|---|
| Architecture | System design and module map |
| Collaboration | Multi-agent workflows |
| Plugin ecosystem | Built-in plugins vs Registry |
| Contributing | How to contribute |
| Releasing | Maintainer release checklist |
Issues and Pull Requests are welcome! Please read the Contributing Guide and Code of Conduct first.
MIT License — see LICENSE for details.
Powered by OpenSquad Core
hooks/guard.mjs 111 lines1// Fixture mod for S1/S2 — written in the canonical module shape:
2//
3// export function register(on, options)
4// on(eventName, matcher?, handler) -> { catch(handler) }
5// handler: async ($, e, next) // no `next(e)` call == short-circuit
6//
7// Three handlers on one event, so the chain's three behaviours are all covered:
8// deny, throw→.catch, and pass-through.
9
10const DENIED = 'demo.echo'
11const EXPLODES = 'demo.boom'
12
13export function register(on, options) {
14 // A mod-contributed slash command: registered at session start, dispatched by
15 // the host, answered with `{ text }` which the agent speaks.
16 on('session.start', async ($, e, next) => {
17 await $.command.register({ name: 'demo', description: 'say hello from the fixture mod' })
18 return next(e)
19 })
20
21 on('command.run', { command: 'demo' }, async ($, e) => {
22 return { text: `deny-demo: hello (args=${(e.args || []).join('|')})` }
23 })
24
25 // 1. deny — and prove `$` is usable while handling (this log is a plain
26 // notification; awaiting a host gate is S2's job).
27 on('tool.call', { tool: DENIED }, async ($, e, next) => {
28 $.ui.log(`deny-demo: blocking ${e.tool}`)
29 return { deny: `deny-demo: ${e.tool} is blocked by the fixture mod` }
30 }).catch(async ($, e, next) => {
31 return { deny: `deny-demo: guard itself failed for ${e.tool}` }
32 })
33
34 // 2. throw — the .catch fallback must decide, not the chain.
35 on('tool.call', { tool: EXPLODES }, async ($, e, next) => {
36 throw new Error('deny-demo: intentional explosion')
37 }).catch(async ($, e, next) => {
38 $.ui.log('deny-demo: .catch fired')
39 return { deny: `deny-demo: denied via .catch for ${e.tool}` }
40 })
41
42 // 3. tool-specific fields are spread onto the event, like the reference does
43 // for a Bash-like tool. A guard written against `e.command` must work.
44 on('tool.call', { tool: 'demo.shell' }, async ($, e, next) => {
45 if (typeof e.command !== 'string') return { deny: 'deny-demo: e.command missing' }
46 return { deny: `deny-demo: saw command ${e.command}` }
47 })
48
49 // 4. reentrancy — the handler awaits a privileged member, which round-trips
50 // host→Python→host *while* our own tool.call request is still outstanding.
51 // A non-duplex transport (or a mis-ordered frame dispatcher) deadlocks here.
52 on('tool.call', { tool: 'demo.cwd' }, async ($, e, next) => {
53 const cwd = await $.session.cwd()
54 return { deny: `deny-demo: cwd=${cwd}` }
55 })
56
57 // 5. persistence — per-mod KV must survive between handlers.
58 on('tool.call', { tool: 'demo.remember' }, async ($, e, next) => {
59 const before = await $.store.get('runs')
60 const runs = (typeof before === 'number' ? before : 0) + 1
61 await $.store.set('runs', runs)
62 const keys = await $.store.keys()
63 return { deny: `deny-demo: runs=${runs} keys=${keys.join('+')}` }
64 })
65
66 // 6. the band: elements come from `$.ui.resolve(e)`, exactly like real mods.
67 // Reading state here also exercises a *third* reentrancy site: a host gate
68 // awaited from inside a render handler.
69 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
70 const { Box, Text, Button } = $.ui.resolve(e)
71 const runs = Number(await $.store.get('runs')) || 0
72 const pinged = Number(await $.store.get('pinged')) || 0
73 return Box({
74 flexDirection: 'row',
75 gap: 2,
76 children: [
77 Text({ bold: true, color: 'magenta', children: 'deny-demo' }),
78 Text({ children: `slot=${e.component} runs=${runs} pinged=${pinged}` }),
79 // An attribute outside the whitelist, to prove it is dropped not fatal.
80 Text({ children: 'stray', notARealProp: 1 }),
81 // Refused element, to prove it is dropped with a reason.
82 { type: 'Client', props: { module: './evil.js' } },
83 // `onPress` cannot cross the wire: the host hoists it to an action id and
84 // calls it back with no arguments (it closes over its own `$`).
85 Button({
86 key: 'ping',
87 label: 'Ping',
88 onPress: async () => {
89 const n = Number(await $.store.get('pinged')) || 0
90 await $.store.set('pinged', n + 1)
91 return 'pong'
92 },
93 }),
94 ],
95 })
96 })
97
98 // 6b. session state — in-memory, per mod, lives as long as the host does.
99 on('tool.call', { tool: 'demo.state' }, async ($, e, next) => {
100 await $.state.set('counter', (Number($.state.get('counter')) || 0) + 1)
101 $.state.update('label', () => 'labelled')
102 const keys = $.state.keys().sort()
103 return { deny: `deny-demo: state=${$.state.get('counter')} keys=${keys.join('+')}` }
104 })
105
106 // 7. pass-through — matcher omitted, must simply delegate downstream.
107 on('tool.call', async ($, e, next) => {
108 return next(e)
109 })
110}
111