SLOPSHOPPER

deny-demo

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

newbandguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · deny-demo
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /demo ⎿ deny-demo: deny-demo: hello (args=) deny-demo slot=AbovePrompt runs=0 pinged=0 stray ▣ client module ./evil.js [ Ping ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
deny-demo slot=AbovePrompt runs=0 pinged=0 stray ▣ client module ./evil.js [ Ping ]
README

<img src="doc_en/banner.svg" alt="OpenSquad" width="720" />

<strong>English</strong> | <a href="README_ZH.md">中文</a>

CI License: MIT Release Python 3.11+ Node 18+ Code style: ruff

Stars Forks Issues Last commit PRs Welcome

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


What is OpenSquad

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.

What Problems It Solves

A single AI agent has clear limitations when dealing with complex projects:

  • Limited context window — Cannot handle the full context of requirements, coding, and testing simultaneously
  • Role confusion — One agent juggling multiple roles leads to oversight
  • No parallelism — Serial execution results in low efficiency
  • No review process — No independent QA role for quality assurance

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.

Architecture (overview)

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

Key Features

  • Collab Card driven — Predefined workflow templates (software dev, code review, research, etc.) with PM coordinating the team per card protocol
  • Group chat communication — Agents collaborate via natural language group chat with @mentions, sleep/wake, and status queries
  • Independent process architecture — Each agent runs independently, can be restarted and configured separately
  • Long-term memory — Cross-session semantic memory system for agents to accumulate experience and knowledge
  • Interruptible sleep — Agents can proactively sleep and wait for events, auto-waking on new messages
  • Plugin system — 20 built-in plugins covering search, voice, version control, platform integrations, and more
  • Skill system — Reusable task instructions defined via Markdown files
  • MCP support — Dynamically connect external MCP servers to extend tool capabilities
  • Multi-platform access — Web UI, Telegram, Feishu/Lark, QQ, and other interaction channels
  • Local-first — Data and API keys stay on your machine, no third-party upload required

Quick Start (about 10 minutes)

  1. Clone this repository.
  2. Install with uv sync (or install.bat / install.sh).
  3. Configure LLM: copy a model card template and set api_key in src/model_cards/ (see model cards guide).
  4. Initialize workspace: uv run opensquad init (creates ~/.opensquad/workspace by default).
  5. Start services: uv run opensquad start.
  6. Open UI: http://127.0.0.1:5173 (or Gateway port from system_config).

Prerequisites

  • Python 3.11+ (officially tested on 3.11 / 3.12 / 3.13)
  • Node.js 18+ for frontend development; the Mods host needs Node.js 20.6+ (it loads mods through module.register, and transpiles their .ts / .tsx with a vendored sucrase — so no Node-version-dependent type stripping)
  • A compatible LLM API (DeepSeek, GPT-4, Claude, Gemini, GLM, etc.)

Option 1: One-Click Script (Recommended, Beginner Friendly)

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

Option 2: uv (Recommended)

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

Option 3: pip

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/ and skills/ next to the opensquad package, so opensquad init seeds a runnable workspace and opensquad start finds every service. One gap remains: a fresh pip workspace has no system_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

Option 4: npm (Node.js users)

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.

Option 5: Docker

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

Updating

Use the row that matches how you installed OpenSquad:

Installed viaUpdate withNotes
Desktop app (installer)In-app Check for updates, or opensquad updateFully 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 opensquadpip install --upgrade opensquad
npm install -g opensquad-ainpm install -g opensquad-ai@latestThe 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 commandRe-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.
Dockergit pull && docker compose up -d --buildThe 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.


Services

opensquad start launches all 4 services:

ServicePortDescription
Gateway Backend9555FastAPI backend (WebSocket + HTTP)
Plugin Registry9720Plugin store API
Frontend Dev5173Vite React frontend
Launcher9600Agent process manager

Open http://127.0.0.1:5173 in your browser to create and configure agents via the Web UI.


CLI Commands

CommandDescription
opensquad init [--workspace <path>]Initialize workspace (default: ~/.opensquad/workspace)
opensquad start [--port <port>]Start all services
opensquad stopStop all OpenSquad services (frontend, gateway, launcher, adapters, agent processes)
opensquad statusShow agent and service status
opensquad plugin listList 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

Configuration

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.


Documentation

Start here: Documentation hub → Getting started (EN)

DocumentDescription
ArchitectureSystem design and module map
CollaborationMulti-agent workflows
Plugin ecosystemBuilt-in plugins vs Registry
ContributingHow to contribute
ReleasingMaintainer release checklist

Contributing

Issues and Pull Requests are welcome! Please read the Contributing Guide and Code of Conduct first.


License

MIT License — see LICENSE for details.

Powered by OpenSquad Core

Source 1 files
hooks/guard.mjs 111 lines
1// 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