← back to A2a Lab
add read-only A2A bridge over the live tk cross-agent DM log, verified
89535d8d43188706ee0323771632d5dbc6f29a03 · 2026-08-01 20:06:08 -0700 · Steve
Exposes ~/.claude/tickets/events.jsonl as an A2A agent (skills: agents, inbox).
Ports lib.js inbox/knownAgents read logic; never writes the shared store.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M README.mdM client.pyA tk_agent.py
Diff
commit 89535d8d43188706ee0323771632d5dbc6f29a03
Author: Steve <steve@designerwallcoverings.com>
Date: Sat Aug 1 20:06:08 2026 -0700
add read-only A2A bridge over the live tk cross-agent DM log, verified
Exposes ~/.claude/tickets/events.jsonl as an A2A agent (skills: agents, inbox).
Ports lib.js inbox/knownAgents read logic; never writes the shared store.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
README.md | 28 ++++++--
client.py | 5 +-
tk_agent.py | 232 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 259 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 6e7b355..8e13932 100644
--- a/README.md
+++ b/README.md
@@ -73,10 +73,28 @@ Verified output: client gets `{"result": {"message": {"parts": [{"text": "echo:
silently defaults to legacy `0.3` and the 1.0 handler rejects it with `-32009`.
Always send `A2A-Version: 1.0`.
+## TK Bridge — A2A over the REAL cross-agent DM system (verified)
+
+`tk_agent.py` exposes Steve's live `tk` agent-to-agent DM log
+(`~/.claude/tickets/events.jsonl`) as an A2A-discoverable agent, so any A2A client
+can query it over the standard protocol. **READ-ONLY by design** — it folds/reads
+the event log (faithful port of `ticket-system/lib.js`'s `inbox`/`knownAgents`) and
+never appends a `dm`/`read` event, so it can't pollute the shared store.
+
+```bash
+python tk_agent.py # terminal 1 — 127.0.0.1:41242
+A2A_BASE=http://127.0.0.1:41242 python client.py "agents"
+A2A_BASE=http://127.0.0.1:41242 python client.py "inbox showroom-builder all"
+```
+
+Verified live: "agents" → 214 known agent identities; "inbox <agent> all" → that
+agent's real DMs (mid / from→to / ticket / text) over A2A. Skills: `agents`, `inbox`.
+Writing DMs over A2A would be a separate, **gated** capability (not built).
+
## Next steps (not done — future work)
-- Build a minimal A2A *server* that exposes one of Steve's cabinet agents (or the
- ticket-DM system) as an A2A-discoverable agent with a published agent card.
-- Add an A2A *client* that can call an external agent's card and dispatch a task.
-- Evaluate wiring A2A into the existing inter-session inbox / `tk dm` cross-agent
- messaging so agents can talk over a standard protocol, not just the local jsonl log.
+- Add an A2A *client-side* helper using the SDK's own `create_client` (proto
+ `SendMessageRequest` + async `StreamResponse`) alongside the raw-JSON-RPC client.
+- A gated **write** skill on the TK Bridge (`dm <agent> <text>`) that appends a real
+ `dm` event via `lib.js` `withLock`/`append` — Steve-gated since it writes the shared log.
+- Publish agent cards for real cabinet agents (vp-*) so they interoperate over A2A.
diff --git a/client.py b/client.py
index 61e6a4b..f3c3f19 100644
--- a/client.py
+++ b/client.py
@@ -13,9 +13,12 @@ import json
import sys
import uuid
+import os
+
import httpx
-BASE = "http://127.0.0.1:41241"
+# Override with A2A_BASE to point at another agent (e.g. the tk bridge on :41242).
+BASE = os.environ.get("A2A_BASE", "http://127.0.0.1:41241")
def discover(base: str) -> dict:
diff --git a/tk_agent.py b/tk_agent.py
new file mode 100644
index 0000000..c98df15
--- /dev/null
+++ b/tk_agent.py
@@ -0,0 +1,232 @@
+"""A2A bridge over the REAL tk cross-agent DM system — a2a-sdk 1.1.2.
+
+Exposes Steve's ticket-system agent-to-agent DM log (~/.claude/tickets/events.jsonl)
+as an A2A-discoverable agent, so any A2A client can query it over the standard
+protocol instead of the local jsonl log directly.
+
+READ-ONLY BY DESIGN: it only folds/reads the event log (ports the inbox + known-agents
+logic from ticket-system/lib.js). It NEVER appends a dm/read event, so running it can
+never pollute the shared cross-agent store. (Writing DMs over A2A would be a separate,
+gated capability.)
+
+Skills (drive via the message text):
+ "agents" -> list every known agent identity
+ "inbox <agent>" -> that agent's UNREAD inbox (add "all" for read+unread)
+
+Run: . .venv/bin/activate && python tk_agent.py (listens on 127.0.0.1:41242)
+"""
+from __future__ import annotations
+
+import json
+import uuid
+from pathlib import Path
+
+import uvicorn
+from starlette.applications import Starlette
+
+from a2a.types import (
+ AgentCard,
+ AgentInterface,
+ AgentCapabilities,
+ AgentSkill,
+ Message,
+ Part,
+ Role,
+)
+from a2a.server.agent_execution import AgentExecutor, RequestContext
+from a2a.server.events import EventQueue
+from a2a.server.request_handlers import DefaultRequestHandler
+from a2a.server.tasks import InMemoryTaskStore
+from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
+
+HOST = "127.0.0.1"
+PORT = 41242
+EVENTS = Path.home() / ".claude" / "tickets" / "events.jsonl"
+
+# ── tk store reads (faithful port of ticket-system/lib.js, READ path only) ──────
+_BROADCAST = {"all", "*", "everyone"}
+
+
+def _read_events() -> list[dict]:
+ if not EVENTS.exists():
+ return []
+ out = []
+ for line in EVENTS.read_text(encoding="utf-8").splitlines():
+ if not line.strip():
+ continue
+ try:
+ out.append(json.loads(line))
+ except json.JSONDecodeError:
+ pass
+ return out
+
+
+def _messages() -> dict[str, dict]:
+ m: dict[str, dict] = {}
+ for ev in _read_events():
+ if ev.get("type") == "dm":
+ mid = ev.get("mid")
+ m[mid] = {
+ "mid": mid,
+ "from": ev.get("from") or ev.get("agent") or "",
+ "to": ev.get("to") or "",
+ "text": ev.get("text") or "",
+ "ticket": ev.get("ticket") or "",
+ "re": ev.get("re") or "",
+ "ts": ev.get("ts") or "",
+ "reads": [],
+ }
+ elif ev.get("type") == "read":
+ mm = m.get(ev.get("mid"))
+ if mm and ev.get("agent") and ev["agent"] not in mm["reads"]:
+ mm["reads"].append(ev["agent"])
+ return m
+
+
+def _thread_root(mid: str, mm: dict[str, dict]) -> str:
+ cur, c, seen = mid, mm.get(mid), {mid}
+ while c and c.get("re") and mm.get(c["re"]):
+ if c["re"] in seen:
+ break
+ seen.add(c["re"])
+ cur = c["re"]
+ c = mm.get(cur)
+ return cur
+
+
+def _participants(mid: str, mm: dict[str, dict]) -> set[str]:
+ root = _thread_root(mid, mm)
+ s: set[str] = set()
+ for m in mm.values():
+ if _thread_root(m["mid"], mm) != root:
+ continue
+ if m["from"]:
+ s.add(m["from"])
+ if m["to"] and m["to"] not in _BROADCAST:
+ s.add(m["to"])
+ return s
+
+
+def inbox(agent: str, unread_only: bool = True) -> list[dict]:
+ mm = _messages()
+ out = []
+ for m in mm.values():
+ if m["from"] == agent:
+ continue
+ addressed = (
+ m["to"] == agent
+ or m["to"] in _BROADCAST
+ or agent in _participants(m["mid"], mm)
+ )
+ if not addressed:
+ continue
+ if unread_only and agent in m["reads"]:
+ continue
+ out.append(m)
+ return sorted(out, key=lambda x: x["ts"])
+
+
+def known_agents() -> list[str]:
+ s: set[str] = set()
+ for ev in _read_events():
+ for k in ("agent", "from", "to"):
+ v = ev.get(k)
+ if v and v not in _BROADCAST:
+ s.add(v)
+ return sorted(s)
+
+
+# ── command dispatch (message text -> reply text) ───────────────────────────────
+def handle(text: str) -> str:
+ cmd = (text or "").strip()
+ low = cmd.lower()
+ if low in ("", "help"):
+ return "tk-bridge commands: 'agents' | 'inbox <agent>' (append 'all' for read+unread)"
+ if low == "agents":
+ agents = known_agents()
+ return f"{len(agents)} known agents:\n" + "\n".join(f" • {a}" for a in agents)
+ if low.startswith("inbox"):
+ parts = cmd.split()
+ if len(parts) < 2:
+ return "usage: inbox <agent> [all]"
+ agent = parts[1]
+ unread_only = not (len(parts) > 2 and parts[2].lower() == "all")
+ msgs = inbox(agent, unread_only=unread_only)
+ scope = "unread" if unread_only else "all"
+ if not msgs:
+ return f"inbox({agent}) — 0 {scope} messages"
+ lines = [f"inbox({agent}) — {len(msgs)} {scope} message(s):"]
+ for m in msgs[-15:]:
+ tkt = f" [{m['ticket']}]" if m["ticket"] else ""
+ lines.append(f" {m['mid']} {m['from']}→{m['to']}{tkt}: {m['text'][:90]}")
+ return "\n".join(lines)
+ return f"unknown command {cmd!r} — try 'help'"
+
+
+class TkBridgeExecutor(AgentExecutor):
+ async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
+ try:
+ user_text = context.get_user_input() or ""
+ except Exception:
+ user_text = ""
+ reply = Message(
+ message_id=str(uuid.uuid4()),
+ role=Role.ROLE_AGENT,
+ parts=[Part(text=handle(user_text))],
+ )
+ await event_queue.enqueue_event(reply)
+
+ async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
+ raise NotImplementedError("tk-bridge is read-only; nothing to cancel.")
+
+
+def build_agent_card() -> AgentCard:
+ return AgentCard(
+ name="TK Bridge Agent",
+ description="Read-only A2A bridge over Steve's tk cross-agent DM log. "
+ "Query agent inboxes and the known-agent roster over the A2A protocol.",
+ version="0.1.0",
+ supported_interfaces=[
+ AgentInterface(
+ url=f"http://{HOST}:{PORT}/",
+ protocol_binding="JSONRPC",
+ protocol_version="1.0",
+ )
+ ],
+ capabilities=AgentCapabilities(streaming=False, push_notifications=False),
+ default_input_modes=["text/plain"],
+ default_output_modes=["text/plain"],
+ skills=[
+ AgentSkill(
+ id="inbox",
+ name="Read agent inbox",
+ description="Return an agent's unread (or all) DMs from the tk log.",
+ tags=["tk", "dm", "inbox", "read-only"],
+ examples=["inbox claude-mail", "inbox showroom-builder all"],
+ ),
+ AgentSkill(
+ id="agents",
+ name="List known agents",
+ description="List every agent identity that has acted or been DM'd.",
+ tags=["tk", "roster", "read-only"],
+ examples=["agents"],
+ ),
+ ],
+ )
+
+
+def build_app() -> Starlette:
+ card = build_agent_card()
+ handler = DefaultRequestHandler(
+ agent_executor=TkBridgeExecutor(),
+ task_store=InMemoryTaskStore(),
+ agent_card=card,
+ )
+ return Starlette(
+ routes=[*create_agent_card_routes(card), *create_jsonrpc_routes(handler, "/")]
+ )
+
+
+if __name__ == "__main__":
+ print(f"A2A TK Bridge on http://{HOST}:{PORT} (read-only over {EVENTS})")
+ uvicorn.run(build_app(), host=HOST, port=PORT, log_level="warning")
← 4dc181f add working A2A echo demo (SDK server + raw JSON-RPC client)
·
back to A2a Lab
·
TK Bridge: add gated dm-write skill (TK_BRIDGE_ALLOW_WRITE), b4cdc89 →