opengateways 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.
Files changed (38) hide show
  1. opengateways-0.1.0/PKG-INFO +130 -0
  2. opengateways-0.1.0/README.md +90 -0
  3. opengateways-0.1.0/pyproject.toml +69 -0
  4. opengateways-0.1.0/src/opengateway/__init__.py +4 -0
  5. opengateways-0.1.0/src/opengateway/acp_handlers.py +234 -0
  6. opengateways-0.1.0/src/opengateway/agent_playbook.py +88 -0
  7. opengateways-0.1.0/src/opengateway/api_keys.py +64 -0
  8. opengateways-0.1.0/src/opengateway/audit.py +63 -0
  9. opengateways-0.1.0/src/opengateway/auth.py +333 -0
  10. opengateways-0.1.0/src/opengateway/cli.py +1255 -0
  11. opengateways-0.1.0/src/opengateway/config.py +279 -0
  12. opengateways-0.1.0/src/opengateway/doctor.py +653 -0
  13. opengateways-0.1.0/src/opengateway/file_store.py +247 -0
  14. opengateways-0.1.0/src/opengateway/im.py +641 -0
  15. opengateways-0.1.0/src/opengateway/listen.py +296 -0
  16. opengateways-0.1.0/src/opengateway/mcp_server.py +1135 -0
  17. opengateways-0.1.0/src/opengateway/models.py +449 -0
  18. opengateways-0.1.0/src/opengateway/persistence.py +603 -0
  19. opengateways-0.1.0/src/opengateway/postgres_persistence.py +577 -0
  20. opengateways-0.1.0/src/opengateway/push.py +87 -0
  21. opengateways-0.1.0/src/opengateway/radio.py +308 -0
  22. opengateways-0.1.0/src/opengateway/redis_bus.py +160 -0
  23. opengateways-0.1.0/src/opengateway/search.py +354 -0
  24. opengateways-0.1.0/src/opengateway/server.py +2295 -0
  25. opengateways-0.1.0/src/opengateway/static/agent-kit/SKILL.md +115 -0
  26. opengateways-0.1.0/src/opengateway/static/assets/index-ClrTipTW.js +52 -0
  27. opengateways-0.1.0/src/opengateway/static/assets/index-D1-pOcmB.css +2 -0
  28. opengateways-0.1.0/src/opengateway/static/assets/index-Doj1LQeu.js +108 -0
  29. opengateways-0.1.0/src/opengateway/static/assets/index-evqxdrWn.css +2 -0
  30. opengateways-0.1.0/src/opengateway/static/favicon.svg +1 -0
  31. opengateways-0.1.0/src/opengateway/static/icons.svg +24 -0
  32. opengateways-0.1.0/src/opengateway/static/index.html +35 -0
  33. opengateways-0.1.0/src/opengateway/static/manifest.webmanifest +18 -0
  34. opengateways-0.1.0/src/opengateway/static/og-logo.png +0 -0
  35. opengateways-0.1.0/src/opengateway/static/sw.js +34 -0
  36. opengateways-0.1.0/src/opengateway/store.py +1582 -0
  37. opengateways-0.1.0/src/opengateway/tailscale.py +137 -0
  38. opengateways-0.1.0/src/opengateway/users.py +158 -0
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.3
2
+ Name: opengateways
3
+ Version: 0.1.0
4
+ Summary: OpenGateway — multi-agent collaboration hub for any harness (ACP + MCP)
5
+ Keywords: agents,acp,mcp,multi-agent,gateway,collaboration,tailscale
6
+ Author: MR Dula Enterprise, LLC
7
+ Author-email: MR Dula Enterprise, LLC <matt@mrdula.solutions>
8
+ License: Apache-2.0
9
+ Requires-Dist: fastapi>=0.115
10
+ Requires-Dist: uvicorn[standard]>=0.32
11
+ Requires-Dist: pydantic>=2.0
12
+ Requires-Dist: httpx>=0.27
13
+ Requires-Dist: httpx-sse>=0.4
14
+ Requires-Dist: mcp>=1.0,<2
15
+ Requires-Dist: typer>=0.15
16
+ Requires-Dist: rich>=13.0
17
+ Requires-Dist: sse-starlette>=2.0
18
+ Requires-Dist: python-multipart>=0.0.9
19
+ Requires-Dist: websockets>=14.0
20
+ Requires-Dist: redis>=5.0 ; extra == 'deploy'
21
+ Requires-Dist: psycopg[binary]>=3.1 ; extra == 'deploy'
22
+ Requires-Dist: pywebpush>=2.0 ; extra == 'deploy'
23
+ Requires-Dist: pytest>=8.0 ; extra == 'dev'
24
+ Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
25
+ Requires-Dist: httpx>=0.27 ; extra == 'dev'
26
+ Requires-Dist: psycopg[binary]>=3.1 ; extra == 'postgres'
27
+ Requires-Dist: pywebpush>=2.0 ; extra == 'push'
28
+ Requires-Dist: redis>=5.0 ; extra == 'redis'
29
+ Requires-Python: >=3.11
30
+ Project-URL: Homepage, https://opengateways.xyz
31
+ Project-URL: Documentation, https://docs.opengateways.xyz
32
+ Project-URL: Repository, https://github.com/mrdulasolutions/open-gateway-hosted
33
+ Project-URL: Issues, https://github.com/mrdulasolutions/open-gateway-hosted/issues
34
+ Provides-Extra: deploy
35
+ Provides-Extra: dev
36
+ Provides-Extra: postgres
37
+ Provides-Extra: push
38
+ Provides-Extra: redis
39
+ Description-Content-Type: text/markdown
40
+
41
+ # OpenGateway Hosted (SaaS)
42
+
43
+ Private multi-tenant product for **[opengateways.xyz](https://opengateways.xyz)** — humans in **Command**, agents on branded hubs `{org}.hub.opengateways.xyz`.
44
+
45
+ ## HARD RULE
46
+
47
+ ```
48
+ SaaS ≠ OSS ≠ Railway
49
+ ```
50
+
51
+ | | Repo | Infra |
52
+ |--|------|--------|
53
+ | **This product** | `open-gateway-hosted` | **Cloudflare only** + Clerk + Stripe |
54
+ | **Open source core** | [open-gateway](https://github.com/mrdulasolutions/open-gateway) | Self-host (Docker / your cloud) — **not** this control plane |
55
+
56
+ Agents and humans must not wire Railway OSS, `*.railway.app`, or the OSS repo into SaaS env, CI, or hub URLs.
57
+
58
+ ## What ships
59
+
60
+ | Surface | URL | Stack |
61
+ |---------|-----|--------|
62
+ | Marketing | https://opengateways.xyz | `site/` Vite + React |
63
+ | Command | https://command.opengateways.xyz | same Worker SPA + Clerk orgs |
64
+ | **Docs** | https://docs.opengateways.xyz | same SPA, public agent kit |
65
+ | Edge API | https://api.opengateways.xyz | `workers/edge-api` billing + hub-access |
66
+ | Org hub | `https://{slug}.hub.opengateways.xyz` | `workers/hub` CF Containers + Python runtime |
67
+
68
+ ## Quick start (new developer)
69
+
70
+ ```bash
71
+ git clone https://github.com/mrdulasolutions/open-gateway-hosted.git
72
+ cd open-gateway-hosted
73
+
74
+ # 1) Read the map
75
+ # docs/DEVELOPING.md
76
+ # docs/PRODUCTION_ROADMAP.md
77
+ # HOSTED.md
78
+
79
+ # 2) Landing / Command locally
80
+ cd site
81
+ cp .env.example .env.local # VITE_CLERK_PUBLISHABLE_KEY from Clerk Dashboard
82
+ bun install
83
+ bun run dev
84
+ ```
85
+
86
+ Full engineering guide: **[docs/DEVELOPING.md](docs/DEVELOPING.md)**.
87
+
88
+ ## Deploy (production)
89
+
90
+ Push to **`main`** runs **Deploy production** (site + edge-api + hub in parallel).
91
+
92
+ ```bash
93
+ git push origin main
94
+ # or
95
+ gh workflow run "Deploy production" --ref main
96
+ ```
97
+
98
+ Single-service hotfixes: workflows `Deploy site` | `Deploy edge-api` | `Deploy hub` (manual only).
99
+
100
+ Secrets: GitHub Actions `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `VITE_CLERK_PUBLISHABLE_KEY`.
101
+ Cloudflare Secrets Store: `OG_*` only — see [docs/SECRETS.md](docs/SECRETS.md).
102
+
103
+ ## Docs index
104
+
105
+ | Doc | Purpose |
106
+ |-----|---------|
107
+ | [HOSTED.md](HOSTED.md) | Architecture, domains, Clerk/Stripe, smoke |
108
+ | [docs/DEVELOPING.md](docs/DEVELOPING.md) | Day-1 engineer path |
109
+ | [docs/PRODUCTION_ROADMAP.md](docs/PRODUCTION_ROADMAP.md) | **P0/P1/P2 backlog for GA** |
110
+ | [docs/RANDOM_USER_FUNNEL.md](docs/RANDOM_USER_FUNNEL.md) | Cold-start stranger path |
111
+ | [docs/SAAS_PRODUCTION.md](docs/SAAS_PRODUCTION.md) | Security + Stripe cutover notes |
112
+ | [docs/AGENTS_IM.md](docs/AGENTS_IM.md) | **True IM** — `opengateway im` event→agent wake |
113
+ | [docs/AGENTS_RADIO.md](docs/AGENTS_RADIO.md) | Radio = presence only (anti max-iterations) |
114
+ | [docs/ORG_TEARDOWN.md](docs/ORG_TEARDOWN.md) | Org delete: soft 30d grace → hard purge |
115
+ | [docs/CLERK_WEBHOOKS.md](docs/CLERK_WEBHOOKS.md) | Clerk endpoint + required events |
116
+ | [docs/HUB_ROTATION.md](docs/HUB_ROTATION.md) | Master rotation / backup |
117
+ | [docs/SECRETS.md](docs/SECRETS.md) | OG_* secret map |
118
+ | [AGENTS.local.md](AGENTS.local.md) | Rules for coding agents |
119
+
120
+ ## Tests
121
+
122
+ ```bash
123
+ uv run pytest # Python hub unit tests
124
+ cd e2e && bun install && bun run test # public smoke vs production
125
+ ```
126
+
127
+ ## License
128
+
129
+ Proprietary for hosted product code (`site/`, SaaS workers, product docs).
130
+ OpenGateway core remains Apache-2.0 in the OSS repo.
@@ -0,0 +1,90 @@
1
+ # OpenGateway Hosted (SaaS)
2
+
3
+ Private multi-tenant product for **[opengateways.xyz](https://opengateways.xyz)** — humans in **Command**, agents on branded hubs `{org}.hub.opengateways.xyz`.
4
+
5
+ ## HARD RULE
6
+
7
+ ```
8
+ SaaS ≠ OSS ≠ Railway
9
+ ```
10
+
11
+ | | Repo | Infra |
12
+ |--|------|--------|
13
+ | **This product** | `open-gateway-hosted` | **Cloudflare only** + Clerk + Stripe |
14
+ | **Open source core** | [open-gateway](https://github.com/mrdulasolutions/open-gateway) | Self-host (Docker / your cloud) — **not** this control plane |
15
+
16
+ Agents and humans must not wire Railway OSS, `*.railway.app`, or the OSS repo into SaaS env, CI, or hub URLs.
17
+
18
+ ## What ships
19
+
20
+ | Surface | URL | Stack |
21
+ |---------|-----|--------|
22
+ | Marketing | https://opengateways.xyz | `site/` Vite + React |
23
+ | Command | https://command.opengateways.xyz | same Worker SPA + Clerk orgs |
24
+ | **Docs** | https://docs.opengateways.xyz | same SPA, public agent kit |
25
+ | Edge API | https://api.opengateways.xyz | `workers/edge-api` billing + hub-access |
26
+ | Org hub | `https://{slug}.hub.opengateways.xyz` | `workers/hub` CF Containers + Python runtime |
27
+
28
+ ## Quick start (new developer)
29
+
30
+ ```bash
31
+ git clone https://github.com/mrdulasolutions/open-gateway-hosted.git
32
+ cd open-gateway-hosted
33
+
34
+ # 1) Read the map
35
+ # docs/DEVELOPING.md
36
+ # docs/PRODUCTION_ROADMAP.md
37
+ # HOSTED.md
38
+
39
+ # 2) Landing / Command locally
40
+ cd site
41
+ cp .env.example .env.local # VITE_CLERK_PUBLISHABLE_KEY from Clerk Dashboard
42
+ bun install
43
+ bun run dev
44
+ ```
45
+
46
+ Full engineering guide: **[docs/DEVELOPING.md](docs/DEVELOPING.md)**.
47
+
48
+ ## Deploy (production)
49
+
50
+ Push to **`main`** runs **Deploy production** (site + edge-api + hub in parallel).
51
+
52
+ ```bash
53
+ git push origin main
54
+ # or
55
+ gh workflow run "Deploy production" --ref main
56
+ ```
57
+
58
+ Single-service hotfixes: workflows `Deploy site` | `Deploy edge-api` | `Deploy hub` (manual only).
59
+
60
+ Secrets: GitHub Actions `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `VITE_CLERK_PUBLISHABLE_KEY`.
61
+ Cloudflare Secrets Store: `OG_*` only — see [docs/SECRETS.md](docs/SECRETS.md).
62
+
63
+ ## Docs index
64
+
65
+ | Doc | Purpose |
66
+ |-----|---------|
67
+ | [HOSTED.md](HOSTED.md) | Architecture, domains, Clerk/Stripe, smoke |
68
+ | [docs/DEVELOPING.md](docs/DEVELOPING.md) | Day-1 engineer path |
69
+ | [docs/PRODUCTION_ROADMAP.md](docs/PRODUCTION_ROADMAP.md) | **P0/P1/P2 backlog for GA** |
70
+ | [docs/RANDOM_USER_FUNNEL.md](docs/RANDOM_USER_FUNNEL.md) | Cold-start stranger path |
71
+ | [docs/SAAS_PRODUCTION.md](docs/SAAS_PRODUCTION.md) | Security + Stripe cutover notes |
72
+ | [docs/AGENTS_IM.md](docs/AGENTS_IM.md) | **True IM** — `opengateway im` event→agent wake |
73
+ | [docs/AGENTS_RADIO.md](docs/AGENTS_RADIO.md) | Radio = presence only (anti max-iterations) |
74
+ | [docs/ORG_TEARDOWN.md](docs/ORG_TEARDOWN.md) | Org delete: soft 30d grace → hard purge |
75
+ | [docs/CLERK_WEBHOOKS.md](docs/CLERK_WEBHOOKS.md) | Clerk endpoint + required events |
76
+ | [docs/HUB_ROTATION.md](docs/HUB_ROTATION.md) | Master rotation / backup |
77
+ | [docs/SECRETS.md](docs/SECRETS.md) | OG_* secret map |
78
+ | [AGENTS.local.md](AGENTS.local.md) | Rules for coding agents |
79
+
80
+ ## Tests
81
+
82
+ ```bash
83
+ uv run pytest # Python hub unit tests
84
+ cd e2e && bun install && bun run test # public smoke vs production
85
+ ```
86
+
87
+ ## License
88
+
89
+ Proprietary for hosted product code (`site/`, SaaS workers, product docs).
90
+ OpenGateway core remains Apache-2.0 in the OSS repo.
@@ -0,0 +1,69 @@
1
+ [project]
2
+ # Dist name on PyPI (opengateway was already taken by an unrelated project).
3
+ # Import package remains `opengateway`; CLI entry points: opengateway + opengateways.
4
+ name = "opengateways"
5
+ version = "0.1.0"
6
+ description = "OpenGateway — multi-agent collaboration hub for any harness (ACP + MCP)"
7
+ readme = "README.md"
8
+ authors = [
9
+ { name = "MR Dula Enterprise, LLC", email = "matt@mrdula.solutions" }
10
+ ]
11
+ requires-python = ">=3.11"
12
+ license = { text = "Apache-2.0" }
13
+ keywords = ["agents", "acp", "mcp", "multi-agent", "gateway", "collaboration", "tailscale"]
14
+ dependencies = [
15
+ "fastapi>=0.115",
16
+ "uvicorn[standard]>=0.32",
17
+ "pydantic>=2.0",
18
+ "httpx>=0.27",
19
+ "httpx-sse>=0.4",
20
+ "mcp>=1.0,<2",
21
+ "typer>=0.15",
22
+ "rich>=13.0",
23
+ "sse-starlette>=2.0",
24
+ "python-multipart>=0.0.9",
25
+ "websockets>=14.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=8.0",
31
+ "pytest-asyncio>=0.24",
32
+ "httpx>=0.27",
33
+ ]
34
+ redis = [
35
+ "redis>=5.0",
36
+ ]
37
+ postgres = [
38
+ "psycopg[binary]>=3.1",
39
+ ]
40
+ push = [
41
+ "pywebpush>=2.0",
42
+ ]
43
+ deploy = [
44
+ "redis>=5.0",
45
+ "psycopg[binary]>=3.1",
46
+ "pywebpush>=2.0",
47
+ ]
48
+
49
+ [project.scripts]
50
+ opengateway = "opengateway.cli:app"
51
+ opengateways = "opengateway.cli:app"
52
+
53
+ [project.urls]
54
+ Homepage = "https://opengateways.xyz"
55
+ Documentation = "https://docs.opengateways.xyz"
56
+ Repository = "https://github.com/mrdulasolutions/open-gateway-hosted"
57
+ Issues = "https://github.com/mrdulasolutions/open-gateway-hosted/issues"
58
+
59
+ [build-system]
60
+ requires = ["uv_build>=0.11.3,<0.12.0"]
61
+ build-backend = "uv_build"
62
+
63
+ # Dist name is opengateways; import package stays opengateway/
64
+ [tool.uv.build-backend]
65
+ module-name = "opengateway"
66
+
67
+ [tool.pytest.ini_options]
68
+ asyncio_mode = "auto"
69
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ """OpenGateway — multi-agent collaboration hub for any harness."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
@@ -0,0 +1,234 @@
1
+ """Built-in ACP agents hosted by OpenGateway."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from opengateway.models import Message, Run, RunStatus, text_message, utcnow
8
+ from opengateway.store import Store
9
+
10
+
11
+ def _text(role: str, content: str) -> Message:
12
+ return text_message(role, content)
13
+
14
+
15
+ BUILTIN_AGENTS: list[dict[str, Any]] = [
16
+ {
17
+ "name": "room-facilitator",
18
+ "description": (
19
+ "OpenGateway facilitator. Summarizes a room's state, participants, open tasks, "
20
+ "and recent messages. Pass room_id in input text as 'room:<id>' or plain room id."
21
+ ),
22
+ "metadata": {"org.open-gateway/kind": "facilitator"},
23
+ },
24
+ {
25
+ "name": "room-broadcast",
26
+ "description": (
27
+ "Post a message into a room on behalf of a system/agent. "
28
+ "Input: first line 'room:<id>', second line 'from:<participant_id>', rest is body."
29
+ ),
30
+ "metadata": {"org.open-gateway/kind": "broadcast"},
31
+ },
32
+ {
33
+ "name": "echo",
34
+ "description": "Echoes input back (health / ACP smoke test).",
35
+ "metadata": {"org.open-gateway/kind": "utility"},
36
+ },
37
+ ]
38
+
39
+
40
+ def _extract_text(messages: list[Message]) -> str:
41
+ return "\n".join(m.text() for m in messages).strip()
42
+
43
+
44
+ def _parse_kv_header(text: str) -> tuple[dict[str, str], str]:
45
+ """Parse leading key:value lines until a blank line or non-kv line."""
46
+ headers: dict[str, str] = {}
47
+ lines = text.splitlines()
48
+ body_start = 0
49
+ for i, line in enumerate(lines):
50
+ if ":" in line and not line.startswith(" "):
51
+ key, _, val = line.partition(":")
52
+ key = key.strip().lower()
53
+ if key in {"room", "room_id", "from", "from_participant_id", "to"}:
54
+ headers[key] = val.strip()
55
+ body_start = i + 1
56
+ continue
57
+ body_start = i
58
+ break
59
+ else:
60
+ body_start = len(lines)
61
+ body = "\n".join(lines[body_start:]).strip()
62
+ # Normalize aliases
63
+ if "room_id" in headers and "room" not in headers:
64
+ headers["room"] = headers["room_id"]
65
+ if "from_participant_id" in headers and "from" not in headers:
66
+ headers["from"] = headers["from_participant_id"]
67
+ return headers, body
68
+
69
+
70
+ async def run_echo(run: Run, messages: list[Message], store: Store) -> Run:
71
+ run.status = RunStatus.IN_PROGRESS
72
+ text = _extract_text(messages) or "(empty)"
73
+ run.output = [_text("agent/echo", text)]
74
+ run.status = RunStatus.COMPLETED
75
+ run.finished_at = utcnow()
76
+ await store.save_run(run)
77
+ return run
78
+
79
+
80
+ async def run_room_facilitator(run: Run, messages: list[Message], store: Store) -> Run:
81
+ run.status = RunStatus.IN_PROGRESS
82
+ text = _extract_text(messages)
83
+ headers, body = _parse_kv_header(text)
84
+ room_id = headers.get("room") or body.strip() or None
85
+
86
+ if not room_id:
87
+ # Pick the most recently updated active room if only one exists
88
+ rooms = await store.list_rooms()
89
+ if len(rooms) == 1:
90
+ room_id = rooms[0].id
91
+ else:
92
+ run.output = [
93
+ _text(
94
+ "agent/room-facilitator",
95
+ "Provide a room id (e.g. `room:<uuid>`) or create/join a room first.\n"
96
+ f"Known rooms: {', '.join(r.name + '=' + r.id for r in rooms) or '(none)'}",
97
+ )
98
+ ]
99
+ run.status = RunStatus.COMPLETED
100
+ run.finished_at = utcnow()
101
+ await store.save_run(run)
102
+ return run
103
+
104
+ room = await store.get_room(room_id)
105
+ if not room:
106
+ run.output = [_text("agent/room-facilitator", f"Room not found: {room_id}")]
107
+ run.status = RunStatus.FAILED
108
+ run.error = "room_not_found"
109
+ run.finished_at = utcnow()
110
+ await store.save_run(run)
111
+ return run
112
+
113
+ participants = await store.list_participants(room_id)
114
+ tasks = await store.list_tasks(room_id)
115
+ messages_list = await store.list_messages(room_id, limit=12)
116
+ artifacts = await store.list_artifacts(room_id)
117
+
118
+ open_tasks = [t for t in tasks if t.status.value in {"open", "claimed", "in_progress", "blocked"}]
119
+ lines = [
120
+ f"# Room: {room.name}",
121
+ f"id: {room.id}",
122
+ f"status: {room.status.value}",
123
+ f"goal: {room.goal or '(none)'}",
124
+ f"project_path: {room.project_path or '(none)'}",
125
+ "",
126
+ f"## Participants ({len(participants)})",
127
+ ]
128
+ for p in participants:
129
+ lines.append(f"- {p.name} [{p.harness.value}] role={p.role} status={p.status.value} id={p.id}")
130
+ if not participants:
131
+ lines.append("- (none)")
132
+
133
+ lines.append("")
134
+ lines.append(f"## Open / active tasks ({len(open_tasks)})")
135
+ for t in open_tasks:
136
+ owner = t.claimed_by or "unclaimed"
137
+ lines.append(f"- [{t.status.value}] {t.title} (id={t.id}, by={owner})")
138
+ if not open_tasks:
139
+ lines.append("- (none)")
140
+
141
+ lines.append("")
142
+ lines.append(f"## Recent messages ({len(messages_list)})")
143
+ for m in messages_list[-8:]:
144
+ target = f" → {m.to_participant_id}" if m.to_participant_id else ""
145
+ lines.append(f"- {m.from_name}{target}: {m.message.text()[:200]}")
146
+ if not messages_list:
147
+ lines.append("- (none yet — agents should post_message after joining)")
148
+
149
+ lines.append("")
150
+ lines.append(f"## Artifacts ({len(artifacts)})")
151
+ for a in artifacts:
152
+ lines.append(f"- {a.name} ({a.content_type}) shared_by={a.shared_by}")
153
+ if not artifacts:
154
+ lines.append("- (none)")
155
+
156
+ lines.append("")
157
+ lines.append("## Suggested next steps")
158
+ if len(participants) < 2:
159
+ lines.append("1. Invite another agent to join this room via MCP `join_room`.")
160
+ if open_tasks:
161
+ lines.append("1. Unclaimed work: agents should `claim_task` then `complete_task`.")
162
+ else:
163
+ lines.append("1. Coordinator should `create_task` to decompose the goal.")
164
+ lines.append("2. Use `post_message` for coordination; share outputs via `share_artifact`.")
165
+ lines.append("3. Poll with `poll_messages` so every harness stays in sync.")
166
+
167
+ run.output = [_text("agent/room-facilitator", "\n".join(lines))]
168
+ run.status = RunStatus.COMPLETED
169
+ run.finished_at = utcnow()
170
+ await store.save_run(run)
171
+ return run
172
+
173
+
174
+ async def run_room_broadcast(run: Run, messages: list[Message], store: Store) -> Run:
175
+ from opengateway.models import RoomMessage
176
+
177
+ run.status = RunStatus.IN_PROGRESS
178
+ text = _extract_text(messages)
179
+ headers, body = _parse_kv_header(text)
180
+ room_id = headers.get("room")
181
+ from_id = headers.get("from")
182
+
183
+ if not room_id or not from_id or not body:
184
+ run.output = [
185
+ _text(
186
+ "agent/room-broadcast",
187
+ "Usage:\nroom:<room_id>\nfrom:<participant_id>\n\n<message body>",
188
+ )
189
+ ]
190
+ run.status = RunStatus.FAILED
191
+ run.error = "invalid_input"
192
+ run.finished_at = utcnow()
193
+ await store.save_run(run)
194
+ return run
195
+
196
+ participant = await store.get_participant(from_id)
197
+ if not participant:
198
+ run.output = [_text("agent/room-broadcast", f"Unknown participant: {from_id}")]
199
+ run.status = RunStatus.FAILED
200
+ run.error = "unknown_participant"
201
+ run.finished_at = utcnow()
202
+ await store.save_run(run)
203
+ return run
204
+
205
+ msg = RoomMessage(
206
+ room_id=room_id,
207
+ from_participant_id=from_id,
208
+ from_name=participant.name,
209
+ to_participant_id=headers.get("to"),
210
+ message=_text(f"agent/{participant.name}", body),
211
+ )
212
+ await store.post_message(msg)
213
+ run.output = [_text("agent/room-broadcast", f"Posted message {msg.id} to room {room_id}")]
214
+ run.status = RunStatus.COMPLETED
215
+ run.finished_at = utcnow()
216
+ await store.save_run(run)
217
+ return run
218
+
219
+
220
+ async def dispatch_acp_run(run: Run, messages: list[Message], store: Store) -> Run:
221
+ handlers = {
222
+ "echo": run_echo,
223
+ "room-facilitator": run_room_facilitator,
224
+ "room-broadcast": run_room_broadcast,
225
+ }
226
+ handler = handlers.get(run.agent_name)
227
+ if not handler:
228
+ run.status = RunStatus.FAILED
229
+ run.error = f"unknown_agent:{run.agent_name}"
230
+ run.output = [_text("agent/opengateway", f"Unknown agent: {run.agent_name}")]
231
+ run.finished_at = utcnow()
232
+ await store.save_run(run)
233
+ return run
234
+ return await handler(run, messages, store)
@@ -0,0 +1,88 @@
1
+ """Public agent playbooks + skill (no secrets). Served without private git."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ _TOPICS: dict[str, str] = {
9
+ "connect": """# Connect an agent (SaaS)
10
+
11
+ You do **not** need the private SaaS git repo.
12
+
13
+ 1. Install CLI: `uv tool install opengateways` (or `uvx opengateways …`)
14
+ - PyPI name is **opengateways** (CLI entry still `opengateway`)
15
+ 2. In Command → Live Ops → create agent token (shown once)
16
+ 3. Env:
17
+ - `OPENGATEWAY_URL=https://{slug}.hub.opengateways.xyz`
18
+ - `OPENGATEWAY_AUTH_TOKEN=ogk_…`
19
+ - `OPENGATEWAY_HARNESS=…` / `OPENGATEWAY_AGENT_NAME=…`
20
+ 4. MCP: `uvx opengateways mcp` (or `opengateway mcp`)
21
+ 5. Restart harness; `begin_im_mode` then work + `drain_inbox` (no wait loops)
22
+
23
+ Docs: https://docs.opengateways.xyz/agents/connect
24
+ """,
25
+ "radio": """# Radio (presence)
26
+
27
+ Background long-poll → Live Ops `presence=listening` + message buffer.
28
+ Radio does **not** run the LLM or post replies.
29
+
30
+ Pattern: join → radio on → work → drain_inbox ≤1/turn → post_message.
31
+ **Never** loop `wait_for_messages` (burns tool iterations).
32
+
33
+ Docs: https://docs.opengateways.xyz/agents/radio
34
+ """,
35
+ "im": """# IM (event → agent wake)
36
+
37
+ `opengateway im <room> --name … --harness … --wake hermes|claude|grok|auto`
38
+
39
+ Presence + wake agent turn on every inbound room message.
40
+ Desktop chat is not always-on IM.
41
+
42
+ Docs: https://docs.opengateways.xyz/agents/im
43
+ """,
44
+ "mcp": """# MCP tools (summary)
45
+
46
+ join_room / begin_im_mode, drain_inbox, post_message, ensure_radio,
47
+ list_rooms, tasks, artifacts, wait_for_messages (one-shot only).
48
+
49
+ Resources: opengateway://gateway, rooms, radio, listen-playbook, playbook/{topic}
50
+ """,
51
+ }
52
+
53
+
54
+ def playbook(topic: Optional[str] = None) -> str:
55
+ t = (topic or "connect").strip().lower() or "connect"
56
+ if t in {"default", "overview", "index", ""}:
57
+ t = "connect"
58
+ body = _TOPICS.get(t)
59
+ if body:
60
+ return body.strip() + "\n"
61
+ known = ", ".join(sorted(_TOPICS))
62
+ return f"# Unknown topic `{t}`\n\nKnown: {known}\n\nDocs: https://docs.opengateways.xyz/\n"
63
+
64
+
65
+ def skill_markdown() -> str:
66
+ """Prefer packaged skills/ tree; fall back to embedded collab summary."""
67
+ candidates = [
68
+ Path(__file__).resolve().parents[2] / "skills" / "opengateway-collab" / "SKILL.md",
69
+ Path(__file__).resolve().parent / "static" / "agent-kit" / "SKILL.md",
70
+ ]
71
+ for p in candidates:
72
+ try:
73
+ if p.is_file():
74
+ return p.read_text(encoding="utf-8")
75
+ except OSError:
76
+ continue
77
+ return """---
78
+ name: opengateway-collab
79
+ description: Collaborate via OpenGateway multi-agent rooms.
80
+ ---
81
+
82
+ # OpenGateway collaboration
83
+
84
+ Install CLI from PyPI (`uv tool install opengateways`). Mint a device key in Command.
85
+ Radio for presence; `opengateway im` for auto-reply. Never loop wait_for_messages.
86
+
87
+ https://docs.opengateways.xyz/agents/connect
88
+ """
@@ -0,0 +1,64 @@
1
+ """Per-device API keys with scoped roles (hashed at rest)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import secrets
7
+ from typing import Any, Iterable, Optional
8
+
9
+ # Scopes
10
+ SCOPE_ADMIN = "admin" # full control incl. key management
11
+ SCOPE_WRITE = "write" # mutate rooms/messages/tasks
12
+ SCOPE_READ = "read" # GET only
13
+ SCOPE_PAIR = "pair" # create pair links
14
+ SCOPE_PUSH = "push" # manage own push subscription
15
+ ALL_SCOPES = (SCOPE_ADMIN, SCOPE_WRITE, SCOPE_READ, SCOPE_PAIR, SCOPE_PUSH)
16
+
17
+
18
+ def hash_secret(secret: str) -> str:
19
+ return hashlib.sha256(secret.encode("utf-8")).hexdigest()
20
+
21
+
22
+ def generate_secret() -> str:
23
+ """Create a high-entropy bearer secret (shown once)."""
24
+ return f"ogk_{secrets.token_urlsafe(32)}"
25
+
26
+
27
+ def prefix_of(secret: str) -> str:
28
+ return secret[:12] if len(secret) >= 12 else secret
29
+
30
+
31
+ def normalize_scopes(scopes: Optional[Iterable[str]]) -> list[str]:
32
+ if not scopes:
33
+ return [SCOPE_WRITE, SCOPE_READ]
34
+ out: list[str] = []
35
+ for s in scopes:
36
+ s = (s or "").strip().lower()
37
+ if s in ALL_SCOPES and s not in out:
38
+ out.append(s)
39
+ if SCOPE_ADMIN in out:
40
+ return [SCOPE_ADMIN]
41
+ if not out:
42
+ out = [SCOPE_READ]
43
+ return out
44
+
45
+
46
+ def scopes_allow(scopes: list[str], method: str, path: str) -> bool:
47
+ """Return True if scopes permit this HTTP method + path."""
48
+ m = (method or "GET").upper()
49
+ p = path or "/"
50
+ if SCOPE_ADMIN in scopes:
51
+ return True
52
+ # Key management is admin-only
53
+ if p.startswith("/v1/keys"):
54
+ return False
55
+ if p.startswith("/v1/audit") and m != "GET":
56
+ return False
57
+ if m in {"GET", "HEAD", "OPTIONS"}:
58
+ return SCOPE_READ in scopes or SCOPE_WRITE in scopes or SCOPE_PAIR in scopes
59
+ if p.startswith("/v1/pair"):
60
+ return SCOPE_PAIR in scopes or SCOPE_WRITE in scopes
61
+ if p.startswith("/v1/push"):
62
+ return SCOPE_PUSH in scopes or SCOPE_WRITE in scopes or SCOPE_READ in scopes
63
+ # Mutations
64
+ return SCOPE_WRITE in scopes