{"slug": "show-hn-xyzzy-ai-teamwork-in-one-python-process-with-a-tamper-evident-log", "title": "Show HN: Xyzzy – AI teamwork in one Python process, with a tamper-evident log", "summary": "Project Nexus YR released Xyzzy, an open-source (Apache-2.0) Python framework for multi-human and AI-agent teamwork with a tamper-evident, hash-chained event log, available via Docker (ghcr.io/project-nexus-yr/xyzzy) and on GitHub. The tool features human-in-the-loop approval, persistent rooms, artifact versioning, and immutable Decision Briefs, with support for Ollama, LM Studio, or any OpenAI-compatible server. It is designed to bring governed, provable, and self-hosted AI collaboration to teams.", "body_md": "A team makes a hard technical decision with AI, and keeps the receipts.\n\nLive page: [xyzzy.yasserameur-dev.workers.dev](https://xyzzy.yasserameur-dev.workers.dev/)\n\n```\ndocker run -p 8000:8000 -e XYZZY_DEMO=1 ghcr.io/project-nexus-yr/xyzzy\n```\n\nOpens a seeded demo workspace at `http://localhost:8000`\n\n, signed in with one click. No account,\nno config. Prefer to run from source? `git clone`\n\nthis repo and run `docker compose --profile demo up`\n\ninstead (see [Docker](#docker) below for the non-demo path).\n\nOne click drops you into a workspace already mid-decision: a channel conversation, a branch with two specialist outputs to compare, and a published Decision Brief with its evidence chain intact. No API key is configured for this recording, so the specialist outputs and the brief show the conspicuously labelled SIMULATED workflow output described above; the collaboration mechanics are the same either way.\n\nModern AI tools are single-player: one human, one chat, one context. Real work happens in teams. XYZZY lets multiple humans and AI agents share a room: a common event history, artifacts, tasks, and decisions, persisted in SQLite with WebSocket-driven real-time sync. Agents branch out in parallel, a human selects or excludes what comes back, and the room publishes an immutable Decision Brief with the evidence chain behind it.\n\n**Governed.** Actions wait for human approval before they execute. What an agent may do is\nre-read from the room's own state at the moment it acts, so leaving a room or losing access takes\neffect immediately, mid-task.\n\n**Provable.** Every room's event log is hash-chained: each event is hashed against the one\nbefore it, so altering or deleting a row breaks every hash after it: tamper-evident by\nconstruction, checkable with the audit CLI. Each Decision links to the Claims and AgentOutputs\nbehind it, so a synthesis is inspectable down to the run that produced it.\n\n**Yours.** One Python process and a SQLite file, self-hosted. Point specialists at Ollama, LM\nStudio, or any OpenAI-compatible server instead of a hosted API. Apache-2.0 licensed, source included.\n\n**Persistent rooms** with durable event sourcing (every action is an ordered event)**Multi-agent orchestration:** spawn, pause, resume, redirect, and delegate between agents**Human-in-the-loop:** request/approve/reject agent actions before execution**Artifact versioning:** create and version documents, code, and other artifacts**Selective synthesis:** explicitly include/exclude outputs and publish immutable Decision Briefs**Evidence ontology:** typed, reviewable Decision → Claim → AgentOutput relationships**Bounded Meta:** permission-aware “why” and decision-evidence answers with exact drill-down**Decision tracking:** record and audit architectural and product decisions**Shared memory:** room-scoped, workspace-scoped, and org-scoped memory**Real-time collaboration:** WebSocket broadcasting of all room events**Reconnect support:** full state snapshot + incremental event replay on reconnect\n\n```\n┌─────────────────────────────────────────────────────────┐\n│                    Browser (web/index.html)              │\n│                    WebSocket + REST API                  │\n└──────────────────────────┬──────────────────────────────┘\n                           │\n┌──────────────────────────▼──────────────────────────────┐\n│                 FastAPI Server (server.py)               │\n│          REST endpoints (routes.py) + WS endpoint        │\n├─────────────────────────────────────────────────────────┤\n│              Service Layer (service.py)                  │\n│     State machines · Input validation · Authorization   │\n├──────────────────┬──────────────────────────────────────┤\n│   RealtimeHub    │        NexusAgentBridge              │\n│  Pub/sub lock    │  AgentExecutor · Budget · Events     │\n│  Queue delivery  │  Pause/Resume/Cancel · Interventions  │\n├──────────────────┴──────────────────────────────────────┤\n│            Repository Layer (repositories.py)           │\n│   16 typed repos · Atomic event sequencing              │\n├─────────────────────────────────────────────────────────┤\n│           Database Layer (connection.py)                │\n│         aiosqlite · WAL mode · Transaction support       │\n├─────────────────────────────────────────────────────────┤\n│                NEXUS Runtime (optional)                  │\n│    AgentExecutor · ModelProvider · PolicyEngine          │\n│    ToolRegistry · SQLiteStateStore · EventBus            │\n└─────────────────────────────────────────────────────────┘\nsrc/multiplayer/\n├── domain/\n│   ├── models.py          # 25+ domain models (frozen dataclasses)\n│   └── events.py          # 40+ event types, RoomEvent, OrgEvent\n├── db/\n│   ├── connection.py      # aiosqlite wrapper with transaction support\n│   └── repositories.py    # 16 typed repository classes\n├── migrations/\n│   └── 0NN_*.sql           # numbered migrations, applied in order at startup\n├── services/\n│   ├── service.py         # Core service layer with state machines\n│   └── presence.py        # In-memory presence tracking\n├── nexus_bridge/\n│   └── agent_bridge.py    # NEXUS runtime adapter (asyncio.Lock protected)\n├── realtime/\n│   ├── hub.py             # Pub/sub with lock-protected mutations\n│   └── websocket.py       # WebSocket endpoint handler\n├── api/\n│   └── routes.py          # 40+ REST endpoints\n└── server.py              # Uvicorn entry point with lifespan\nweb/\n└── index.html             # Single-page workspace UI\ntests/\n├── unit/                  # Domain model tests\n├── integration/           # Repository, service, API tests\n├── concurrency/           # Concurrent event generation, hub, bridge\n├── security/              # State machines, approvals, scope isolation\n├── failure/               # Error handling, validation, stub tests\n└── regression/            # Reconnect correctness\n```\n\nXYZZY includes an optional integration with [NEXUS](https://github.com/Project-Nexus-YR/NEXUS), a lightweight agent runtime. The `NexusAgentBridge`\n\nadapts NEXUS into the multiplayer context:\n\n**AgentExecutor** manages agent run lifecycle (create, reason, pause, resume, cancel)**Budget** enforces token limits, wall time, and tool call limits**PolicyEngine** gates tool access per agent and room**StateStore** persists agent state for checkpoint/restart\n\nWhen NEXUS is unavailable, the bridge runs the configured model provider directly. With an\n`OPENAI_API_KEY`\n\n, specialists use the OpenAI Responses API. Without a credential, XYZZY emits a\nconspicuously labelled `SIMULATED WORKFLOW OUTPUT`\n\nso collaboration mechanics remain testable\nwithout presenting placeholder text as real analysis.\n\nPython 3.11 or newer and nothing else. The database is a file, so there is no service to stand up first.\n\nmacOS and Linux:\n\n```\ngit clone https://github.com/Project-Nexus-YR/XYZZY.git\ncd XYZZY\npython3 -m venv .venv\nsource .venv/bin/activate\npip install -e \".[dev]\"\n```\n\nWindows PowerShell:\n\n```\ngit clone https://github.com/Project-Nexus-YR/XYZZY.git\ncd XYZZY\npython -m venv .venv\n.venv\\Scripts\\Activate.ps1\npip install -e \".[dev]\"\n```\n\nmacOS has shipped no `python`\n\ncommand since 12.3, and `/usr/bin/python3`\n\nis a\nstub that offers to install the Command Line Tools rather than an interpreter\nworth building against, so create the virtualenv with a real `python3`\n\n(`brew install python@3.13`\n\n, or the installer from python.org). Once `.venv`\n\nis\nactivated, plain `python`\n\nis that virtualenv's interpreter and every command\nbelow works as written. Apple silicon needs nothing special: every dependency\nresolves to an arm64 wheel.\n\nEvery route below `/api/v1`\n\nneeds a bearer token; `/api/v1/health`\n\nis the\nexception. Without `OPENAI_API_KEY`\n\nthe server runs a credential-free\nsimulator, which is enough for the whole workflow.\n\nCredentials live in the database, hashed, one row per token, revocable\nwithout a restart. `XYZZY_AUTH_TOKENS`\n\nis bootstrap only: its tokens are\ningested at startup, and a token an operator revoked stays revoked across\nrestarts. Mint and revoke real credentials with the operator CLI: the token\nis printed once at mint time and never stored:\n\n```\npython -m multiplayer.manage multiplayer.db user add alice --email alice@example.com\npython -m multiplayer.manage multiplayer.db token mint alice --label laptop\npython -m multiplayer.manage multiplayer.db token revoke <token-or-hash>\npython -m multiplayer.manage multiplayer.db token list\n# Start the server (serves API + web UI). POSIX shells - macOS zsh, Linux bash:\nexport XYZZY_AUTH_TOKENS='{\"local-dev-token\":\"user_local\"}'\nexport OPENAI_API_KEY=\"...\"                 # optional; simulated when unset\nexport XYZZY_OPENAI_MODEL=\"gpt-5.4-mini\" # optional; this is the default\nexport XYZZY_MODEL_TIMEOUT_SECONDS=\"45\"  # optional\npython -m multiplayer.server\n\n# Open browser\n# http://localhost:8000\n```\n\nSet `XYZZY_LOCAL_MODEL_BASE_URL`\n\nto point specialists at any OpenAI-compatible\nchat-completions server instead of the OpenAI API (Ollama, LM Studio, vLLM,\nand llama.cpp's server all qualify). It takes priority over `OPENAI_API_KEY`\n\nwhen both are set. `XYZZY_OPENAI_MODEL`\n\nstill names the model; `OPENAI_API_KEY`\n\nis optional here and, when set, is sent as a bearer token to the host the base\nURL names, so unset the key (or use a placeholder) when pointing at a local\nruntime you do not want your OpenAI key sent to.\n\n```\n# Ollama\nexport XYZZY_LOCAL_MODEL_BASE_URL=\"http://localhost:11434/v1\"\nexport XYZZY_OPENAI_MODEL=\"llama3\"\n\n# LM Studio\nexport XYZZY_LOCAL_MODEL_BASE_URL=\"http://localhost:1234/v1\"\nexport XYZZY_OPENAI_MODEL=\"local-model\"\n# Windows PowerShell: `export` is not a PowerShell verb.\n$env:XYZZY_AUTH_TOKENS = '{\"local-dev-token\":\"user_local\"}'\npython -m multiplayer.server\n```\n\nThe model credential is never accepted from an API request, written to SQLite, or included in an\nagent output. Requests send only the selected specialist's name, role, template instructions, the\nuser decision prompt, and any explicit human intervention. Responses API storage is disabled with\n`store: false`\n\n.\n\n`XYZZY_AUTH_TOKENS`\n\nis a server-owned JSON map from opaque Bearer tokens to user IDs. Empty or\nmissing configuration denies every non-health request. The browser keeps its token in memory only.\nThe server binds to `127.0.0.1:8000`\n\nand persists to `multiplayer.db`\n\nby default; pass an explicit\ndatabase path as the first CLI argument when needed.\n\nEvery one of these has a working default, so a local run needs none of them. A deployment that terminates TLS in front of the server needs the first three.\n\n| Variable | Default | What it decides |\n|---|---|---|\n`XYZZY_HOST` |\n`127.0.0.1` |\nInterface to bind. Loopback by default: binding everything because nobody configured it is a deployment decision made by omission. |\n`XYZZY_PORT` |\n`8000` |\nPort to bind. |\n`XYZZY_CORS_ORIGINS` |\nthe two loopback origins | Comma-separated browser origins allowed to call the API. `*` is refused: paired with credentials it would let any site spend a signed-in session. |\n`XYZZY_RATE_LIMIT_PER_MINUTE` |\n`120` |\nRequests per minute per bearer token, or per peer address when there is no token. `/api/v1/health` is exempt so a monitor cannot spend a client's budget. |\n`XYZZY_MAX_BODY_BYTES` |\n`1048576` |\nLargest declared request body. A chunked request declares no length, so this caps the honest case only. |\n`XYZZY_LOG_LEVEL` |\n`INFO` |\nRoot log level. |\n\nThe rate limiter counts in process memory. It bounds one server's exposure, not a fleet's; two replicas behind a load balancer each allow the full budget.\n\n`GET /api/v1/health`\n\nis a readiness probe, not a liveness one: it reads from the\ndatabase and answers 503 when it cannot, so a process holding an unopenable\ndatabase is never reported ready.\n\n`GET /metrics`\n\nexposes this process's own counters and gauges in Prometheus\ntext format, exempt from auth and from the rate limiter like `/health`\n\n. It is\nsingle-process: scrape each replica rather than expecting one to speak for a\nfleet.\n\nSSO is additive. With none of these set the server behaves exactly as before:\nbootstrap tokens and `manage token mint`\n\n, so a deployment without a provider is\nuntouched.\n\n| Variable | What it decides |\n|---|---|\n`XYZZY_OIDC_ISSUER` |\nThe provider's issuer URL. Its configuration is discovered from `{issuer}/.well-known/openid-configuration` . |\n`XYZZY_OIDC_CLIENT_ID` |\nThis deployment's client id. |\n`XYZZY_OIDC_CLIENT_SECRET` |\nOptional; omit for a public client relying on PKCE alone. |\n`XYZZY_OIDC_REDIRECT_URI` |\nWhere the provider sends the browser back. |\n`XYZZY_OIDC_SCOPES` |\nSpace separated; `openid profile email` by default. |\n`XYZZY_OIDC_POST_LOGOUT_REDIRECTS` |\nComma-separated allowlist. A redirect target taken from a request would be an open redirect. |\n`XYZZY_SESSION_IDLE_SECONDS` |\nIdle clock, 1800 by default (Keycloak's). |\n`XYZZY_SESSION_ABSOLUTE_SECONDS` |\nAbsolute ceiling, 36000 by default (Keycloak's). |\n`XYZZY_SESSION_ACCESS_SECONDS` |\nHow long one access credential lives before it must be refreshed, 300 by default (Keycloak's). |\n`XYZZY_OIDC_ALLOW_UNVERIFIABLE_SESSIONS` |\nAccept a login from a provider that issues no refresh token. Off by default, because such a session can never be re-checked; when on, it is capped at 15 minutes. |\n\n`GET /api/v1/auth/login`\n\nstarts the flow, `GET /api/v1/auth/callback`\n\nfinishes it\nand returns an access token and a refresh token, `POST /api/v1/auth/refresh`\n\nrotates them, `POST /api/v1/auth/logout`\n\nends this session,\n`POST /api/v1/auth/logout-everywhere`\n\nends all of them, and\n`POST /api/v1/auth/backchannel-logout`\n\naccepts the provider's logout token.\nEvery one of them sits under the `/api/v1`\n\nprefix, so `XYZZY_OIDC_REDIRECT_URI`\n\nmust too.\n\nThree things worth knowing before you deploy it. A refresh token is spendable\nonce, and presenting a spent one revokes the entire session rather than that\ntoken: a replay means a copy exists somewhere it should not, and revoking only\nthe copy leaves whoever holds the original inside. And an SSO login is keyed on\nthe provider's issuer and subject, never on the email address, so it does **not**\nattach to an operator-created account that happens to share an email. Linking\nthose is a deliberate act; inferring it from a string is how accounts get taken\nover. And there is no reuse grace window: a refresh\nwhose answer is lost cannot be retried, and the person signs in again. A window\nwas tried and removed, because it let a thief presenting the stolen predecessor\ntake a working session and leave the victim's own next refresh to be judged the\nreplay. Keycloak's default is no reuse either.\n\nEvery refresh also spends the provider's own refresh token, so a person disabled, locked out, or password-reset upstream loses this session at the next rotation rather than at the absolute clock.\n\nThe browser itself never sees either token. `GET /api/v1/auth/callback`\n\nsets a\ncookie only when the request prefers `text/html`\n\n(a browser arriving by\nredirect); that cookie carries the access token alone, HttpOnly, `__Host-`\n\nprefixed on an HTTPS deployment, and expires with the session's idle clock.\nEvery other caller (curl, an agent, `refresh`\n\n/`logout`\n\n) still gets the JSON\nbody with both tokens, unchanged. A cookie authenticates an HTTP request only\nwhen it also carries header `X-XYZZY-Client: web`\n\n, on every method including\nGET, which is what keeps a mutating GET like `/auth/end-session`\n\nout of CSRF\nreach: a cross-origin request cannot attach a custom header without a CORS\npreflight `XYZZY_CORS_ORIGINS`\n\nrefuses, and a top-level navigation cannot\nattach one at all. A cookie-authed WebSocket cannot carry that header either,\nso it is gated on `Origin`\n\nmatching `configured_origins()`\n\nexactly instead.\n\n**Trying it locally:** `scripts/dev_idp.py`\n\nis a throwaway identity provider:\nstdlib/FastAPI, one hardcoded user, a fresh RS256 key generated on every start.\nIt refuses to run unless its own issuer is a loopback host, because it trusts\nevery caller completely.\n\n```\npython scripts/dev_idp.py --port 9100\n# in another shell\nexport XYZZY_OIDC_ISSUER=\"http://127.0.0.1:9100\"\nexport XYZZY_OIDC_CLIENT_ID=\"dev-client\"\nexport XYZZY_OIDC_REDIRECT_URI=\"http://127.0.0.1:8000/api/v1/auth/callback\"\npython -m multiplayer.server\n```\n\nOpen [http://localhost:8000](http://localhost:8000) and sign in through the provider; `XYZZY_DEV_IDP_SUB`\n\n,\n`XYZZY_DEV_IDP_NAME`\n\n, and `XYZZY_DEV_IDP_EMAIL`\n\nchange the one user's claims.\n\nXYZZY speaks Google's [A2A](https://a2a-protocol.org/) v0.3.0, so an agent built\nagainst somebody else's runtime can be asked for work here, and one of ours can\nask it back.\n\n`GET /.well-known/agent-card.json`\n\nis the discovery document and needs no\ncredential. It advertises the door and **no agents at all**: a room's membership\nis the access-control decision, so a public list of agents and their skills\nwould publish the shape of a private workspace to anyone who fetched a URL. The\nauthenticated `agent/getAuthenticatedExtendedCard`\n\nshows each caller only the\nagents that caller could actually address, which means no two callers share one\ndocument.\n\n`POST /a2a/v1`\n\nis the JSON-RPC 2.0 endpoint: `message/send`\n\n, `message/stream`\n\n,\n`tasks/get`\n\n, `tasks/cancel`\n\n, `tasks/resubscribe`\n\n,\n`agent/getAuthenticatedExtendedCard`\n\n, and the two `tasks/pushNotificationConfig`\n\nmethods. The card advertises `pushNotifications: false`\n\nand those two refuse by\nname, because a webhook fan-out would be a second delivery path with weaker guarantees\nthan the durable ordered log clients already have. Streaming is\nServer-Sent-Events over that same log, not a parallel one.\n\nA2A addresses one agent per URL and this server fronts many rooms, so\n`message.metadata`\n\ncarries `roomId`\n\nand `targetAgentId`\n\n. A caller who may not act\nin a room gets the same refusal whether the agent is real, filed elsewhere, or\nimaginary; a task you may not read answers exactly as a task that does not exist.\n\nTwo rules about delegation are worth knowing before you wire agents to each other. What a delegate may spend is its asker's own authority intersected with its own, re-read from durable rows at the moment of spending: narrow the asker mid-task and the delegate narrows with it, and an asker that has left the room lends nothing. And the chain a delegation belongs to is read from the delegating agent's own open run rather than taken from the request, so an agent cannot start a fresh chain by declining to name its parent: a cycle is refused by name, and a chain deeper than four delegations is too.\n\n**Quickstart:**\n\n```\ngit clone <this repo> && cd xyzzy\ndocker compose up\n```\n\nOpen [http://localhost:8000](http://localhost:8000) and sign in with the dev token\n`change-me-dev-token`\n\n. Replace that token in `docker-compose.yml`\n\nbefore\ndeploying anywhere real.\n\nWithout `docker compose`\n\n, the equivalent is:\n\n```\ndocker build -t xyzzy .\ndocker run -p 8000:8000 -v xyzzy-data:/data -e XYZZY_AUTH_TOKENS='{\"local-dev-token\":\"user_local\"}' xyzzy\n```\n\nNo account, no config, nothing to try alone: `docker compose --profile demo up`\n\n(or\n`docker run -p 8000:8000 -e XYZZY_DEMO=1 ghcr.io/project-nexus-yr/xyzzy`\n\n, the published image;\nsee [Try it](#try-it) above) opens a seeded demo workspace at [http://localhost:8000](http://localhost:8000), signed in\nwith one click.\n\nThe database is a file under `/data`\n\n. Without the volume the room history dies\nwith the container.\n\n```\n# All tests\npython -m pytest tests/ -v\n\n# Specific suites\npython -m pytest tests/unit/ -v\npython -m pytest tests/concurrency/ -v\npython -m pytest tests/security/ -v\npython -m pytest tests/failure/ -v\npython -m pytest tests/regression/ -v\n```\n\nThe current repository gate is 954 passing tests plus Ruff format/check and strict `mypy src`\n\n,\nrun on every push and pull request by `.github/workflows/ci.yml`\n\n.\nThe suite covers:\n\n- Unit tests for domain models\n- Integration tests for repositories, services, and API endpoints\n- Concurrency tests for event sequencing, hub pub/sub, and agent bridge locks\n- Security tests for state machines, approval workflows, and room isolation\n- Failure injection tests for error handling and validation\n- Regression tests for reconnect correctness\n- File-backed acknowledgement latency and exact zero-loss event persistence\n\nOne process is the default and the recommendation until a real deployment\noutgrows it. When one does, set `XYZZY_REDIS_URL`\n\n(install with\n`pip install \"xyzzy[redis]\"`\n\n) and run several server processes against the\nsame database file: room events, session revocations, and user notifications\nfan out across processes through Redis pub/sub, and presence stays correct\ncluster-wide through keys that expire on silence. Redis carries no state\nworth backing up. If it goes down, each process degrades to single-process\nbehavior and clients recover anything missed through the reconnect replay\npath, because the event log stays the single source of truth.\n\nTwo boundaries to respect: all processes must share one real local filesystem for the database (network filesystems such as NFS or SMB are unsupported), and rate limits count per process, so divide the budget or limit at the load balancer.\n\nCI verifies provider behavior against a fake HTTP transport on every push,\nwhich keeps the gates free and deterministic. The `live-provider`\n\nworkflow\nis the opt-in other half: trigger it by hand (Actions tab) with an\n`OPENAI_API_KEY`\n\nrepository secret configured, and it spends one real API\ncall proving the genuine provider path produces model-written output.\nLocally, the same test runs whenever the key is exported and skips loudly\nwhen it is not.\n\nApache 2.0, see [LICENSE](/Project-Nexus-YR/XYZZY/blob/main/LICENSE).", "url": "https://wpnews.pro/news/show-hn-xyzzy-ai-teamwork-in-one-python-process-with-a-tamper-evident-log", "canonical_source": "https://github.com/Project-Nexus-YR/XYZZY", "published_at": "2026-08-31 18:58:45+00:00", "updated_at": "2026-08-31 19:22:55.288683+00:00", "lang": "en", "topics": ["ai-tools", "ai-agents", "ai-infrastructure", "ai-safety"], "entities": ["Project Nexus YR", "Xyzzy", "Ollama", "LM Studio"], "alternates": {"html": "https://wpnews.pro/news/show-hn-xyzzy-ai-teamwork-in-one-python-process-with-a-tamper-evident-log", "markdown": "https://wpnews.pro/news/show-hn-xyzzy-ai-teamwork-in-one-python-process-with-a-tamper-evident-log.md", "text": "https://wpnews.pro/news/show-hn-xyzzy-ai-teamwork-in-one-python-process-with-a-tamper-evident-log.txt", "jsonld": "https://wpnews.pro/news/show-hn-xyzzy-ai-teamwork-in-one-python-process-with-a-tamper-evident-log.jsonld"}}