[object Object]

← 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

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 →