agent-switchboard 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_switchboard-0.1.0/.dockerignore +9 -0
- agent_switchboard-0.1.0/.github/workflows/ci.yml +72 -0
- agent_switchboard-0.1.0/.github/workflows/publish.yml +46 -0
- agent_switchboard-0.1.0/.gitignore +13 -0
- agent_switchboard-0.1.0/CONTRIBUTING.md +78 -0
- agent_switchboard-0.1.0/Dockerfile +40 -0
- agent_switchboard-0.1.0/LICENSE +21 -0
- agent_switchboard-0.1.0/PKG-INFO +354 -0
- agent_switchboard-0.1.0/README.md +314 -0
- agent_switchboard-0.1.0/docker-compose.yml +19 -0
- agent_switchboard-0.1.0/docs/api.md +178 -0
- agent_switchboard-0.1.0/docs/claude-code.md +253 -0
- agent_switchboard-0.1.0/docs/codex-cli.md +127 -0
- agent_switchboard-0.1.0/docs/concepts.md +158 -0
- agent_switchboard-0.1.0/docs/deployment.md +319 -0
- agent_switchboard-0.1.0/docs/encryption.md +531 -0
- agent_switchboard-0.1.0/docs/managed-hub.md +318 -0
- agent_switchboard-0.1.0/docs/quickstart.md +192 -0
- agent_switchboard-0.1.0/examples/coordinated_worker.py +155 -0
- agent_switchboard-0.1.0/pyproject.toml +77 -0
- agent_switchboard-0.1.0/src/switchboard/__init__.py +86 -0
- agent_switchboard-0.1.0/src/switchboard/auth.py +209 -0
- agent_switchboard-0.1.0/src/switchboard/cli.py +1033 -0
- agent_switchboard-0.1.0/src/switchboard/client.py +762 -0
- agent_switchboard-0.1.0/src/switchboard/config.py +156 -0
- agent_switchboard-0.1.0/src/switchboard/crypto.py +332 -0
- agent_switchboard-0.1.0/src/switchboard/mcp_server.py +628 -0
- agent_switchboard-0.1.0/src/switchboard/notify.py +67 -0
- agent_switchboard-0.1.0/src/switchboard/server.py +639 -0
- agent_switchboard-0.1.0/src/switchboard/store.py +918 -0
- agent_switchboard-0.1.0/tests/test_api.py +355 -0
- agent_switchboard-0.1.0/tests/test_auth.py +416 -0
- agent_switchboard-0.1.0/tests/test_cli_init.py +259 -0
- agent_switchboard-0.1.0/tests/test_cli_register_key.py +139 -0
- agent_switchboard-0.1.0/tests/test_cli_serve.py +83 -0
- agent_switchboard-0.1.0/tests/test_crypto.py +693 -0
- agent_switchboard-0.1.0/tests/test_mcp.py +355 -0
- agent_switchboard-0.1.0/tests/test_notify.py +234 -0
- agent_switchboard-0.1.0/tests/test_store.py +425 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: pip install -e '.[dev]'
|
|
21
|
+
- run: ruff check .
|
|
22
|
+
- run: pytest -q
|
|
23
|
+
|
|
24
|
+
# The client, CLI and MCP bridge must install and work with httpx alone.
|
|
25
|
+
# This job fails if a server-only import leaks into them.
|
|
26
|
+
minimal-install:
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v4
|
|
30
|
+
- uses: actions/setup-python@v5
|
|
31
|
+
with:
|
|
32
|
+
python-version: "3.12"
|
|
33
|
+
- run: pip install -e .
|
|
34
|
+
- name: import client, CLI and MCP bridge without server or crypto extras
|
|
35
|
+
run: |
|
|
36
|
+
python -c "import switchboard, switchboard.cli, switchboard.mcp_server, switchboard.crypto"
|
|
37
|
+
python - <<'PY'
|
|
38
|
+
import importlib.util as u
|
|
39
|
+
leaked = [p for p in ("fastapi", "uvicorn", "starlette", "cryptography")
|
|
40
|
+
if u.find_spec(p)]
|
|
41
|
+
assert not leaked, f"optional deps leaked into the base install: {leaked}"
|
|
42
|
+
# crypto.py must still import without cryptography installed, so the
|
|
43
|
+
# error a user gets is an actionable message rather than ImportError
|
|
44
|
+
# at the top of an unrelated module.
|
|
45
|
+
import switchboard.crypto as c
|
|
46
|
+
assert c.AVAILABLE is False
|
|
47
|
+
try:
|
|
48
|
+
c.WorkspaceCipher.from_key("a" * 44, "w")
|
|
49
|
+
except c.CryptoError as exc:
|
|
50
|
+
assert "crypto extra" in str(exc), exc
|
|
51
|
+
else:
|
|
52
|
+
raise AssertionError("expected a CryptoError naming the extra")
|
|
53
|
+
PY
|
|
54
|
+
- run: switchboard --version
|
|
55
|
+
- name: the MCP bridge must answer initialize with no hub running
|
|
56
|
+
run: |
|
|
57
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
|
|
58
|
+
| switchboard-mcp | grep -q '"serverInfo"'
|
|
59
|
+
|
|
60
|
+
docker:
|
|
61
|
+
runs-on: ubuntu-latest
|
|
62
|
+
steps:
|
|
63
|
+
- uses: actions/checkout@v4
|
|
64
|
+
- run: docker build -t agent-switchboard:ci .
|
|
65
|
+
- name: hub answers /health
|
|
66
|
+
run: |
|
|
67
|
+
docker run -d --name swb -p 8787:8787 -e SWITCHBOARD_TOKEN=ci agent-switchboard:ci
|
|
68
|
+
for i in $(seq 1 30); do
|
|
69
|
+
curl -sf http://127.0.0.1:8787/health && break || sleep 1
|
|
70
|
+
done
|
|
71
|
+
curl -sf http://127.0.0.1:8787/health | grep -q '"ok":true'
|
|
72
|
+
docker rm -f swb
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Runs on a GitHub Release being published. Uses PyPI Trusted Publishing
|
|
4
|
+
# (OIDC) rather than a stored API token — nothing secret lives in this repo
|
|
5
|
+
# or in GitHub Actions secrets. The trust relationship (this repo + this
|
|
6
|
+
# workflow filename + the `pypi` environment) is configured once on PyPI's
|
|
7
|
+
# side: https://pypi.org/manage/account/publishing/
|
|
8
|
+
#
|
|
9
|
+
# To cut a release: bump `version` in pyproject.toml, merge, then
|
|
10
|
+
# `gh release create vX.Y.Z --generate-notes` (tag must match the version).
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
release:
|
|
14
|
+
types: [published]
|
|
15
|
+
|
|
16
|
+
permissions:
|
|
17
|
+
contents: read
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
build:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
- uses: actions/setup-python@v5
|
|
25
|
+
with:
|
|
26
|
+
python-version: "3.12"
|
|
27
|
+
- run: python -m pip install --upgrade build
|
|
28
|
+
- run: python -m build
|
|
29
|
+
- run: python -m pip install --upgrade twine && python -m twine check dist/*
|
|
30
|
+
- uses: actions/upload-artifact@v4
|
|
31
|
+
with:
|
|
32
|
+
name: dist
|
|
33
|
+
path: dist/
|
|
34
|
+
|
|
35
|
+
publish:
|
|
36
|
+
needs: build
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
environment: pypi
|
|
39
|
+
permissions:
|
|
40
|
+
id-token: write # required for Trusted Publishing
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/download-artifact@v4
|
|
43
|
+
with:
|
|
44
|
+
name: dist
|
|
45
|
+
path: dist/
|
|
46
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
## Setup
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
python -m venv .venv && source .venv/bin/activate
|
|
7
|
+
pip install -e '.[dev]'
|
|
8
|
+
pytest -q
|
|
9
|
+
ruff check .
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
That is the whole toolchain. There is no build step, no code generation, and
|
|
13
|
+
no service to run for the tests — they spin the app up in-process.
|
|
14
|
+
|
|
15
|
+
## Layout
|
|
16
|
+
|
|
17
|
+
| File | What lives there |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `store.py` | SQLite storage. All concurrency correctness is here. |
|
|
20
|
+
| `server.py` | FastAPI app: wire schemas, serialization, HTTP semantics. |
|
|
21
|
+
| `client.py` | Sync + async HTTP clients, and identity detection. |
|
|
22
|
+
| `cli.py` | The `switchboard` command. |
|
|
23
|
+
| `mcp_server.py` | MCP stdio bridge — speaks JSON-RPC directly, no SDK. |
|
|
24
|
+
| `config.py` | Env-driven settings and the TTL defaults/ceilings. |
|
|
25
|
+
|
|
26
|
+
The dependency direction is one-way: `store` knows nothing about HTTP,
|
|
27
|
+
`server` knows nothing about the CLI, and `cli`/`mcp_server` both go through
|
|
28
|
+
`client`. Keep it that way.
|
|
29
|
+
|
|
30
|
+
## Things to preserve
|
|
31
|
+
|
|
32
|
+
**The client, CLI and MCP bridge must depend only on `httpx`.** An agent
|
|
33
|
+
should be able to join a hub without installing FastAPI. CI has a job that
|
|
34
|
+
fails if a server import leaks into them.
|
|
35
|
+
|
|
36
|
+
**The MCP bridge must not take an SDK dependency.** It implements the stdio
|
|
37
|
+
protocol directly so it cannot break when an SDK renames its API between
|
|
38
|
+
majors — which is exactly what happened between `mcp` 1.x and 2.0. If you add
|
|
39
|
+
a protocol method, add a test asserting its wire shape.
|
|
40
|
+
|
|
41
|
+
**Every record must expire.** A new table needs an `expires_at`, a read filter
|
|
42
|
+
on it, and an entry in `sweep()`. Correctness must not depend on the sweeper
|
|
43
|
+
having run — reads filter expiry themselves, and there is a test that says so.
|
|
44
|
+
|
|
45
|
+
**Read-then-write must happen inside `_tx()`.** That is `BEGIN IMMEDIATE`, and
|
|
46
|
+
it is the only reason two agents cannot both win the same lease. If you find
|
|
47
|
+
yourself reading a row and then writing based on what you read, it belongs in
|
|
48
|
+
one transaction.
|
|
49
|
+
|
|
50
|
+
## Testing
|
|
51
|
+
|
|
52
|
+
Tests use synthetic timestamps (`now=1000.0`) rather than sleeping, so expiry
|
|
53
|
+
behaviour is tested exactly and instantly. Pass `now=` explicitly to every
|
|
54
|
+
store call in a test that cares about time — including the assertions, since
|
|
55
|
+
the reads default to wall-clock.
|
|
56
|
+
|
|
57
|
+
`test_store.py::test_concurrent_acquire_yields_exactly_one_winner` is the one
|
|
58
|
+
test that must never be weakened. It runs twelve real threads through a
|
|
59
|
+
barrier at one resource and asserts exactly one winner. If it becomes flaky,
|
|
60
|
+
something is wrong with the locking, not with the test.
|
|
61
|
+
|
|
62
|
+
## Pull requests
|
|
63
|
+
|
|
64
|
+
- Run `ruff check .` and `pytest -q` before pushing.
|
|
65
|
+
- Add a test for anything behavioural. The suite is fast; there is no reason
|
|
66
|
+
not to.
|
|
67
|
+
- If you change the HTTP surface, update `docs/api.md` in the same PR.
|
|
68
|
+
- If you add or change an MCP tool, update its description — the description
|
|
69
|
+
is the entire interface an agent has to the tool, so it needs to say *when*
|
|
70
|
+
to use it, not just what it does.
|
|
71
|
+
|
|
72
|
+
## Design questions
|
|
73
|
+
|
|
74
|
+
Open an issue before building anything that adds a fifth primitive. Four is a
|
|
75
|
+
deliberate ceiling: presence, leases, messages, blackboard. Most proposals
|
|
76
|
+
turn out to be one of those with a different name, and the ones that aren't
|
|
77
|
+
are usually asking Switchboard to be a queue, a log, or a scheduler — all
|
|
78
|
+
things it deliberately is not. See [docs/concepts.md](docs/concepts.md).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Switchboard hub — one process, one SQLite file.
|
|
2
|
+
FROM python:3.12-slim AS build
|
|
3
|
+
|
|
4
|
+
WORKDIR /src
|
|
5
|
+
COPY pyproject.toml README.md LICENSE ./
|
|
6
|
+
COPY src ./src
|
|
7
|
+
RUN pip install --no-cache-dir --upgrade pip build \
|
|
8
|
+
&& python -m build --wheel --outdir /dist
|
|
9
|
+
|
|
10
|
+
FROM python:3.12-slim
|
|
11
|
+
|
|
12
|
+
# The hub holds no source and no credentials, but there is no reason to run
|
|
13
|
+
# it as root either.
|
|
14
|
+
RUN useradd --create-home --uid 10001 switchboard \
|
|
15
|
+
&& mkdir -p /data && chown switchboard:switchboard /data
|
|
16
|
+
|
|
17
|
+
COPY --from=build /dist/*.whl /tmp/
|
|
18
|
+
# The wheel path is resolved into a variable first: `/tmp/*.whl[server]` would
|
|
19
|
+
# be read by the shell as a glob with a [...] character class, which matches
|
|
20
|
+
# nothing and gets passed to pip verbatim.
|
|
21
|
+
RUN wheel="$(ls /tmp/*.whl)" \
|
|
22
|
+
&& pip install --no-cache-dir "${wheel}[server]" \
|
|
23
|
+
&& rm /tmp/*.whl
|
|
24
|
+
|
|
25
|
+
USER switchboard
|
|
26
|
+
WORKDIR /data
|
|
27
|
+
|
|
28
|
+
ENV SWITCHBOARD_DB=/data/switchboard.db \
|
|
29
|
+
PYTHONUNBUFFERED=1
|
|
30
|
+
|
|
31
|
+
EXPOSE 8787
|
|
32
|
+
VOLUME ["/data"]
|
|
33
|
+
|
|
34
|
+
# Kept to one physical line: a backslash-continued CMD inside a quoted Python
|
|
35
|
+
# one-liner is easy to break silently, and a healthcheck that always fails
|
|
36
|
+
# takes the container down.
|
|
37
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
|
38
|
+
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/health', timeout=3)"]
|
|
39
|
+
|
|
40
|
+
ENTRYPOINT ["switchboard", "serve", "--host", "0.0.0.0", "--port", "8787"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Switchboard contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-switchboard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An ephemeral orchestration hub for AI coding agents: presence, leases, messaging and a shared blackboard, all with TTLs.
|
|
5
|
+
Project-URL: Homepage, https://github.com/gald33/switchboard
|
|
6
|
+
Project-URL: Issues, https://github.com/gald33/switchboard/issues
|
|
7
|
+
Author: Switchboard contributors
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,claude,coordination,mcp,orchestration
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Provides-Extra: all
|
|
21
|
+
Requires-Dist: cryptography>=42.0; extra == 'all'
|
|
22
|
+
Requires-Dist: fastapi>=0.110; extra == 'all'
|
|
23
|
+
Requires-Dist: pydantic>=2.6; extra == 'all'
|
|
24
|
+
Requires-Dist: uvicorn[standard]>=0.29; extra == 'all'
|
|
25
|
+
Provides-Extra: crypto
|
|
26
|
+
Requires-Dist: cryptography>=42.0; extra == 'crypto'
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: cryptography>=42.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: fastapi>=0.110; extra == 'dev'
|
|
30
|
+
Requires-Dist: pydantic>=2.6; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
34
|
+
Requires-Dist: uvicorn[standard]>=0.29; extra == 'dev'
|
|
35
|
+
Provides-Extra: server
|
|
36
|
+
Requires-Dist: fastapi>=0.110; extra == 'server'
|
|
37
|
+
Requires-Dist: pydantic>=2.6; extra == 'server'
|
|
38
|
+
Requires-Dist: uvicorn[standard]>=0.29; extra == 'server'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# Switchboard
|
|
42
|
+
|
|
43
|
+
**An ephemeral orchestration hub for AI coding agents.**
|
|
44
|
+
|
|
45
|
+
When several coding agents work the same repo — one on your laptop, one in a
|
|
46
|
+
cloud session, one in CI — they need to coordinate. Today they mostly do it
|
|
47
|
+
through pull request bodies and review comments. That works, but it is the
|
|
48
|
+
wrong medium for most of what they have to say:
|
|
49
|
+
|
|
50
|
+
- **It's permanent.** "I'm taking the migration file, don't touch it for the
|
|
51
|
+
next 20 minutes" is true for 20 minutes and then it is litter in your
|
|
52
|
+
repository history, forever.
|
|
53
|
+
- **It's slow.** A PR comment is a message with a latency floor measured in
|
|
54
|
+
whole review cycles.
|
|
55
|
+
- **It can't say "now".** There is no way to ask *who else is awake right now*,
|
|
56
|
+
and no way to hold a claim that releases itself when you crash.
|
|
57
|
+
|
|
58
|
+
Switchboard is a small hub the agents talk to instead. Everything in it
|
|
59
|
+
**expires on its own**, because coordination state is not a record — it should
|
|
60
|
+
live exactly as long as the work does.
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
local agent ─┐
|
|
64
|
+
cloud agent ─┼─► switchboard hub ─► SQLite (everything TTL'd)
|
|
65
|
+
CI agent ─┘ ▲
|
|
66
|
+
│ MCP tools / CLI / REST
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Four primitives
|
|
72
|
+
|
|
73
|
+
| | What it's for | Default TTL |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| **Presence** | Who is working right now, on what branch, on what task | 2 min |
|
|
76
|
+
| **Leases** | Exclusive claim on a resource key — *expires instead of leaking* | 15 min |
|
|
77
|
+
| **Messages** | Channel pub/sub with per-agent read cursors | 1 hour |
|
|
78
|
+
| **Blackboard** | Shared key/value scratch space for handoffs too big for a message | 24 hours |
|
|
79
|
+
|
|
80
|
+
That's the whole model. Direct messages aren't a fifth concept — a DM to agent
|
|
81
|
+
`bob` is just a message on channel `@bob`.
|
|
82
|
+
|
|
83
|
+
### Why leases expire
|
|
84
|
+
|
|
85
|
+
This is the part worth dwelling on. A conventional "claim" — a row in a table,
|
|
86
|
+
a lock file, a label on an issue — is acquired explicitly and released
|
|
87
|
+
explicitly. The release is the half that gets dropped, because nothing ever
|
|
88
|
+
*asks* an agent whether it is still working. A session crashes, or merges and
|
|
89
|
+
moves on, and its claim sits there holding a piece of work hostage until a
|
|
90
|
+
human notices.
|
|
91
|
+
|
|
92
|
+
A Switchboard lease is acquired explicitly and released **by running out**.
|
|
93
|
+
Agents renew what they hold as a side effect of their heartbeat, so a live
|
|
94
|
+
agent keeps its claims and a dead one gives them up within a minute or two,
|
|
95
|
+
with nobody having to remember anything.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Install
|
|
100
|
+
|
|
101
|
+
Not yet on PyPI — install straight from GitHub for now:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Agent side: client + CLI only (one dependency, httpx)
|
|
105
|
+
pip install "agent-switchboard @ git+https://github.com/gald33/switchboard.git"
|
|
106
|
+
|
|
107
|
+
# Hub side: also the server
|
|
108
|
+
pip install "agent-switchboard[server] @ git+https://github.com/gald33/switchboard.git"
|
|
109
|
+
|
|
110
|
+
# With end-to-end encryption
|
|
111
|
+
pip install "agent-switchboard[crypto] @ git+https://github.com/gald33/switchboard.git"
|
|
112
|
+
|
|
113
|
+
# With the MCP bridge for Claude Code / any MCP client
|
|
114
|
+
pip install "agent-switchboard[all] @ git+https://github.com/gald33/switchboard.git"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Pin a commit or tag instead of tracking `main` once you depend on this for
|
|
118
|
+
real — append it to the URL: `git+https://github.com/gald33/switchboard.git@v0.1.0`.
|
|
119
|
+
|
|
120
|
+
## The fast path
|
|
121
|
+
|
|
122
|
+
From the root of a repo:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
pip install "agent-switchboard[all] @ git+https://github.com/gald33/switchboard.git"
|
|
126
|
+
switchboard init
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
That's one command instead of hand-editing three files: it writes `.mcp.json`,
|
|
130
|
+
adds the `SessionStart`/`Stop` lifecycle hooks to `.claude/settings.json`,
|
|
131
|
+
appends a coordination section to `CLAUDE.md`, and — if you didn't already
|
|
132
|
+
point it at a hub — generates a dev token into a gitignored `.env` so
|
|
133
|
+
`docker compose up -d` just works. It merges into whatever is already in
|
|
134
|
+
those files and is safe to run again. See
|
|
135
|
+
[Use it from Claude Code](#use-it-from-claude-code) below for what it wires
|
|
136
|
+
up and why, or skip straight to `switchboard init --help`.
|
|
137
|
+
|
|
138
|
+
### Getting local, cloud, and CI agents onto the same hub
|
|
139
|
+
|
|
140
|
+
`switchboard init` with no `--url` defaults to a hub on `127.0.0.1:8787` — a
|
|
141
|
+
local dev instance reachable only from the machine it runs on. That's fine
|
|
142
|
+
for two terminals on your laptop, but a cloud Claude Code session or a CI
|
|
143
|
+
runner pointed at that same default would each spin up their *own* local
|
|
144
|
+
hub and never see each other, even though the workspace name (inferred from
|
|
145
|
+
your git remote) matches perfectly. Matching workspace names only matter
|
|
146
|
+
once everyone is actually talking to the same hub.
|
|
147
|
+
|
|
148
|
+
To get local + cloud + CI coordinating with each other:
|
|
149
|
+
|
|
150
|
+
1. Deploy one hub somewhere all three can reach it — see
|
|
151
|
+
[Deployment](docs/deployment.md) for Docker, systemd, and TLS.
|
|
152
|
+
2. Run `switchboard init --url https://your-hub` in the repo and commit the
|
|
153
|
+
`.mcp.json` it writes. Every clone of the repo — laptop, cloud session,
|
|
154
|
+
CI checkout — now points at the same URL and workspace with no further
|
|
155
|
+
config, because that file is part of the repo.
|
|
156
|
+
3. Set `SWITCHBOARD_TOKEN` in each environment separately: your shell
|
|
157
|
+
profile locally, your cloud environment's secrets, your CI provider's
|
|
158
|
+
secrets store. `init` deliberately never writes the token into a
|
|
159
|
+
committed file, so this one step doesn't get automated away — it's the
|
|
160
|
+
one thing each environment has to be told on its own.
|
|
161
|
+
|
|
162
|
+
## Run a hub
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
export SWITCHBOARD_TOKEN="$(python -c 'import secrets;print(secrets.token_urlsafe(32))')"
|
|
166
|
+
switchboard serve --host 0.0.0.0 --port 8787 --db ./switchboard.db
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Or with Docker — no published image yet, so build it from a clone
|
|
170
|
+
(`docker-compose.yml` does the same thing):
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
git clone https://github.com/gald33/switchboard.git && cd switchboard
|
|
174
|
+
docker build -t agent-switchboard .
|
|
175
|
+
docker run -p 8787:8787 -e SWITCHBOARD_TOKEN=secret -v swb:/data agent-switchboard
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The hub is one process and one SQLite file. It holds no source code and no
|
|
179
|
+
credentials — only who is awake and what they are saying to each other — so it
|
|
180
|
+
is cheap to run and cheap to lose.
|
|
181
|
+
|
|
182
|
+
## Point agents at it
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
export SWITCHBOARD_URL=https://hub.example.com
|
|
186
|
+
export SWITCHBOARD_TOKEN=secret
|
|
187
|
+
export SWITCHBOARD_WORKSPACE=my-org/my-repo
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
switchboard whoami # identity inferred from git + host
|
|
192
|
+
switchboard agents # who else is awake
|
|
193
|
+
switchboard claim db/migrations -m "adding 0142"
|
|
194
|
+
switchboard say build "migrations are mine for ~15m"
|
|
195
|
+
switchboard inbox --wait 25 # long-poll for messages
|
|
196
|
+
switchboard release db/migrations
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Use it from Claude Code
|
|
200
|
+
|
|
201
|
+
Add the MCP server (`.mcp.json` in your repo, or `claude mcp add`):
|
|
202
|
+
|
|
203
|
+
```json
|
|
204
|
+
{
|
|
205
|
+
"mcpServers": {
|
|
206
|
+
"switchboard": {
|
|
207
|
+
"command": "switchboard-mcp",
|
|
208
|
+
"env": {
|
|
209
|
+
"SWITCHBOARD_URL": "https://hub.example.com",
|
|
210
|
+
"SWITCHBOARD_TOKEN": "secret",
|
|
211
|
+
"SWITCHBOARD_WORKSPACE": "my-org/my-repo"
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Agents then get these as native tools:
|
|
219
|
+
|
|
220
|
+
| Tool | Does |
|
|
221
|
+
|---|---|
|
|
222
|
+
| `whoami` / `roster` | this agent's identity; who else is awake |
|
|
223
|
+
| `claim` / `release` / `claims` | take, drop and inspect leases |
|
|
224
|
+
| `say` / `dm` / `inbox` / `history` | channel and direct messaging |
|
|
225
|
+
| `board_set` / `board_get` / `board_list` | shared scratch space |
|
|
226
|
+
| `checkin` | heartbeat + renew leases + drain inbox, in one call |
|
|
227
|
+
|
|
228
|
+
`checkin` is the one that matters most in practice: a single tool call that
|
|
229
|
+
keeps the agent alive, renews everything it holds, and hands back anything
|
|
230
|
+
other agents said since last time.
|
|
231
|
+
|
|
232
|
+
See [`docs/claude-code.md`](docs/claude-code.md) for the full setup including a
|
|
233
|
+
`SessionStart` hook that registers the agent automatically and a `Stop` hook
|
|
234
|
+
that releases its leases.
|
|
235
|
+
|
|
236
|
+
The same MCP server works from any MCP-speaking coding agent — see
|
|
237
|
+
[`docs/codex-cli.md`](docs/codex-cli.md) for Codex CLI, which has an
|
|
238
|
+
equivalent `config.toml`-based hook system.
|
|
239
|
+
|
|
240
|
+
## Use it from Python
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
from switchboard import Client, LeaseHeld, detect_identity
|
|
244
|
+
|
|
245
|
+
me = detect_identity()
|
|
246
|
+
with Client(agent_id=me.agent_id) as hub:
|
|
247
|
+
hub.register(name=me.name, kind=me.kind, branch=me.branch, channels=["build"])
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
hub.acquire("db/migrations", note="adding 0142", ttl=900)
|
|
251
|
+
except LeaseHeld as exc:
|
|
252
|
+
print(f"{exc.holder} has it for another {exc.expires_in}s — doing something else")
|
|
253
|
+
else:
|
|
254
|
+
hub.post("build", "migrations are mine for ~15m")
|
|
255
|
+
...
|
|
256
|
+
hub.release("db/migrations")
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Documentation
|
|
262
|
+
|
|
263
|
+
- [Quickstart](docs/quickstart.md) — hub up and two agents talking, in five minutes
|
|
264
|
+
- [Concepts](docs/concepts.md) — the model, the TTL rules, and what Switchboard deliberately is *not*
|
|
265
|
+
- [Claude Code setup](docs/claude-code.md) — MCP config, hooks, and prompt guidance
|
|
266
|
+
- [Codex CLI setup](docs/codex-cli.md) — same idea, `config.toml`-based hooks
|
|
267
|
+
- [Deployment](docs/deployment.md) — Docker, systemd, TLS, backups
|
|
268
|
+
- [HTTP API](docs/api.md) — every endpoint
|
|
269
|
+
- [End-to-end encryption](docs/encryption.md) — run a hub that cannot read its own traffic
|
|
270
|
+
- [Managed hubs](docs/managed-hub.md) — running one *for other people*: multi-tenancy, what actually runs out first, and how congestion should degrade
|
|
271
|
+
|
|
272
|
+
## Encrypt it, and the hub can't read it either
|
|
273
|
+
|
|
274
|
+
A hub only ever needs to *route* and *compare*, never to read. So it doesn't
|
|
275
|
+
have to:
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
switchboard keygen # prints a key, plus an opaque workspace name to pair with it
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Set both on every agent in the workspace. The key never reaches the hub. The
|
|
282
|
+
workspace name *does* — it is the routing key and cannot be encrypted — which
|
|
283
|
+
is why `keygen` hands you an opaque one rather than letting `acme/billing`
|
|
284
|
+
become the most descriptive string the hub holds.
|
|
285
|
+
|
|
286
|
+
Message bodies, blackboard values, lease notes, branch names and task
|
|
287
|
+
descriptions are sealed with AES-256-GCM before they leave the agent. Channel
|
|
288
|
+
names, lease resources and agent ids become opaque tokens the hub can still
|
|
289
|
+
compare for equality — which is all it needs to deliver a message or exclude a
|
|
290
|
+
second lease holder.
|
|
291
|
+
|
|
292
|
+
Costs 1.1µs to encrypt and 0.7µs to decrypt a message, against a ~1000µs
|
|
293
|
+
network round trip. Everything else — `claim`, `inbox`, `checkin`, the MCP
|
|
294
|
+
tools — behaves exactly as before.
|
|
295
|
+
|
|
296
|
+
What the hub stores once you do this:
|
|
297
|
+
|
|
298
|
+
```
|
|
299
|
+
channel = mYkpn3DkU7rhr_Qjk_objQ
|
|
300
|
+
body = {"$swb":1,"n":"ARyT0f4DEQnXsJ2C","c":"4yTX0Vd1QuD_5Y38U7gkkZ5A…"}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The hub needs **no changes and no configuration** to support this — it cannot
|
|
304
|
+
tell an encrypted workspace from a plaintext one, so it cannot be
|
|
305
|
+
misconfigured into weakening one. Plaintext is padded to size buckets before sealing, so message *length* does
|
|
306
|
+
not leak either. What remains is timing, volume, and which opaque tokens are
|
|
307
|
+
equal — [what that reveals, and what hiding more would cost](docs/encryption.md).
|
|
308
|
+
|
|
309
|
+
## Sharing a hub between teams that don't trust each other
|
|
310
|
+
|
|
311
|
+
By default a hub has one token and every caller may use every workspace —
|
|
312
|
+
workspaces are a *namespace*, for keeping one team's coordination out of
|
|
313
|
+
another's way. That is the right shape for a hub your own agents share, and it
|
|
314
|
+
is what you get if you change nothing.
|
|
315
|
+
|
|
316
|
+
If a hub is shared by parties that shouldn't see each other's traffic,
|
|
317
|
+
workspaces become a *boundary* instead. Give `create_app` a resolver that maps
|
|
318
|
+
each key to the workspaces it may touch:
|
|
319
|
+
|
|
320
|
+
```python
|
|
321
|
+
from switchboard import Principal, StaticKeyResolver
|
|
322
|
+
from switchboard.server import create_app # server extra; not at the package root
|
|
323
|
+
|
|
324
|
+
app = create_app(resolver=StaticKeyResolver({
|
|
325
|
+
"key-acme": Principal(key_id="acme", workspaces=frozenset({"acme/app"})),
|
|
326
|
+
"key-globex": Principal(key_id="globex", workspaces=frozenset({"globex/api"})),
|
|
327
|
+
"key-ops": Principal(key_id="ops", workspaces=None), # unrestricted
|
|
328
|
+
}))
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Every workspace-bearing endpoint then returns 403 outside a key's scope,
|
|
332
|
+
enforced in one shared dependency rather than per-handler — see
|
|
333
|
+
[docs/managed-hub.md](docs/managed-hub.md). Clients need no changes.
|
|
334
|
+
|
|
335
|
+
## What this is not
|
|
336
|
+
|
|
337
|
+
- **Not a queue.** No delivery guarantees, no retries, no dead-letter. Messages
|
|
338
|
+
expire whether or not anyone read them. If you need work to survive, put it
|
|
339
|
+
in your issue tracker.
|
|
340
|
+
- **Not an audit log.** It forgets on purpose. Decisions that should outlive the
|
|
341
|
+
work still belong in a commit message, a PR, or a doc.
|
|
342
|
+
- **Not confidential from your own agents.** One key per workspace: everyone in
|
|
343
|
+
it reads everything in it. The encryption keeps out the hub and other
|
|
344
|
+
tenants, not your own colleagues.
|
|
345
|
+
- **Not an identity system.** Keys scope *which workspaces* a caller may touch;
|
|
346
|
+
within a workspace, agents are assumed to trust each other, because they
|
|
347
|
+
already share a codebase. Agent ids tell agents apart, they don't keep them
|
|
348
|
+
apart.
|
|
349
|
+
- **Not a scheduler.** It will tell you a resource is taken. It will not decide
|
|
350
|
+
who should have taken it.
|
|
351
|
+
|
|
352
|
+
## License
|
|
353
|
+
|
|
354
|
+
MIT — see [LICENSE](LICENSE).
|