Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Types and tests, on every push and every pull request.
#
# There is no build step here — tsx runs TypeScript directly — so nothing checks types
# unless something asks. `yarn typecheck` is that ask, and it carries the compiler flags
# in place of the tsconfig.json this repository deliberately does not have.
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
# The active LTS. Node 26 takes that title at the end of October 2026; this is
# pinned rather than `lts/*` so the jump is a commit somebody made on purpose.
node-version: '24'

# Yarn 4, pinned by the packageManager field; Corepack resolves that version itself.
# Deliberately before the install and without setup-node's cache, which reaches for
# the Yarn 1 shim on the runner and asks it about a Berry cache folder.
- run: corepack enable

# --immutable: a lockfile that would have to change is a failure, not a fixup.
- run: yarn install --immutable

- run: yarn typecheck

- run: yarn test
49 changes: 49 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Publishes docs/ to GitHub Pages.
#
# The site is the Markdown already in the repository, rendered by the same Jekyll that
# Pages runs by itself — no generator, no second copy of the documentation, and nothing
# to keep in step with it. Requires Pages to be set to "GitHub Actions" in the repository
# settings; the workflow cannot turn that on for you.
name: Docs

on:
push:
branches: [main]
paths:
- 'docs/**'
- '.github/workflows/docs.yml'
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

# One deployment at a time, and a queued one waits rather than replacing what is live.
concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# These three move together: the artifact format they agree on is what ties a build
# to the deployment that consumes it, so bumping one alone is how Pages jobs break.
- uses: actions/configure-pages@v6
- uses: actions/jekyll-build-pages@v1
with:
source: ./docs
destination: ./_site
- uses: actions/upload-pages-artifact@v5

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
42 changes: 39 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ yarn raw # the same protocol over bare curl, no SDK
yarn tap # wire-tap proxy :41242 → :41241
yarn mcp # MCP endpoint on :41243 fronting the A2A agent, for a calling agent
yarn service # install and drive the whole thing as a background service
yarn typecheck # tsc --noEmit with the flags that stand in for tsconfig.json
```

All three servers listen on the same port and serve the same protocol, so `client`, `raw` and
Expand Down Expand Up @@ -116,6 +117,23 @@ End-to-end verification is unchanged and still worth running: `yarn client` agai
checking the cycle `SUBMITTED → INPUT_REQUIRED → WORKING → artifact → COMPLETED`.
Full write-up: [docs/testing.md](docs/testing.md).

## Continuous integration

`.github/workflows/ci.yml` runs `yarn typecheck` and `yarn test` on every push and every pull
request. The typecheck is there because there is no build step: `tsx` transpiles and throws the
types away, so nothing checks them unless something asks, and the flags that would live in a
`tsconfig.json` live in that script instead. It is expected to pass — the tree is at zero
diagnostics — so a new one is a regression rather than a number to compare against.

`.github/workflows/docs.yml` publishes `docs/` to GitHub Pages through the same Jekyll that Pages
runs anyway, so the site is the Markdown in this repository rather than a second copy of it. It
needs Pages set to "GitHub Actions" in the repository settings; the workflow cannot turn that on
by itself.

Corepack is enabled before the install, because that is what resolves the Yarn version pinned in
`packageManager`. `setup-node`'s cache is deliberately not used: it reaches for the Yarn 1 shim on
the runner and asks it about a Berry cache folder, which is global here anyway.

## Architecture

Each server is an entry point that reads the environment, builds an Agent Card and binds a port;
Expand Down Expand Up @@ -422,6 +440,14 @@ On the ACP side, verified the same way:
- `ClientSideConnection` is deprecated in SDK 1.4.0; the current API is
`acp.client({name}).onRequest(...).connect(stream)`.

## Changelog

`CHANGELOG.md` at the root, Keep a Changelog format. A change to behaviour, configuration or the
protocol surface gets an entry in the same commit that makes it — one sentence on what changed,
and a second on why when the reason is not obvious from the first. Refactors, comments and
documentation edits do not. While a version is unreleased the entry goes under its heading;
once it has gone out, open an `## [Unreleased]` above it rather than editing what shipped.

## Longer prose

`docs/` carries the human-facing documentation: architecture, both protocols, the permission
Expand All @@ -430,7 +456,17 @@ working guide, not the explanation.

## Source of truth

The specification outranks both the SDK and this file: <https://a2a-protocol.org/v1.0.0/specification/>.
The SDK implements v1.0.0; when behavior disagrees, check the spec rather than guessing from the
Three protocols meet here, and each has a specification that outranks both its SDK and this file.
The versions are the ones this repository actually speaks — read off the handshakes, not off the
package names:

| Protocol | Specification | What we speak |
|---|---|---|
| **A2A** | <https://a2a-protocol.org/v1.0.0/specification/> | v1.0.0, via `@a2a-js/sdk` 1.2.0 |
| **ACP** | <https://agentclientprotocol.com/protocol/overview> | `PROTOCOL_VERSION` 1, via `@agentclientprotocol/sdk` 1.4.0 |
| **MCP** | <https://modelcontextprotocol.io/specification/2025-06-18> | `2025-06-18`, the version our `initialize` answers with, via `@modelcontextprotocol/server` 2.0.0 |

For A2A in particular: when behavior disagrees, check the spec rather than guessing from the
bundle — `dist` also ships the v0.3 compatibility layer, and its constants are easy to mistake for
the current ones.
the current ones. The same caution applies to ACP, where the SDK's own version (1.4.0) and the
protocol's (1) are different numbers that both appear in the handshake.
97 changes: 97 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Changelog

Notable changes, newest first. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the numbers follow
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Entries say what changed and, where the reason is not obvious from the change, why — this
repository exists to be read, and a list of verbs is no use to whoever comes next. Findings
referenced as `F01`, `F09` and so on are from [docs/repository-audit.md](docs/repository-audit.md),
which records them in full along with the evidence.

## [0.1.0] — 2026-09-21

The first version worth a number, and an honest one: tasks live in an `InMemoryTaskStore`, the
A2A side runs `UserBuilder.noAuthentication`, and the permission classifier is a placeholder for
an external channel — a human, a policy, or a supervising agent.

### Added

- **An A2A v1.0 server and client** (`src/agent.ts`, `src/client.ts`) — Agent Card discovery,
streaming, `INPUT_REQUIRED` and the artifact cycle, with a placeholder executor (the "Revisor")
that computes text statistics and nothing else.
- **The same protocol over bare curl** (`src/raw.sh`) and **a wire-tap** (`src/proxy.ts`) that
prints raw JSON-RPC and SSE frames, because the SDK surfaces parsed objects and what you need to
see is the wire.
- **The ACP bridge** (`src/acp/`) — the same A2A server with its executor replaced by a real
coding agent addressed over stdin/stdout: one adapter process per `contextId`, a permission
classifier that answers instead of the calling agent, and two rotating JSON Lines logs.
- **The MCP bridge** (`src/mcp/`) — the A2A agent published to a calling agent as four tools over
Streamable HTTP, bearer-guarded, with progress ticks derived from upstream liveness.
`a2a_ask` takes a `taskId` as well as a `contextId`, which is the only way to answer an
`INPUT_REQUIRED` question rather than open a second task beside it, and every call reports the
task and context it reached so an interrupted one can still be recovered (`F09`, `F10`).
- **Deadlines on everything that does not stream.** The silence gate covers `a2a_ask`;
`MCP_DISCOVERY_TIMEOUT_MS` (20 s) bounds the shared agent handshake and
`MCP_REQUEST_TIMEOUT_MS` (30 s) bounds `a2a_task`, `a2a_cancel` and `a2a_card`. Discovery is
bounded separately because it is shared: a caller's abort ends only its own wait, while the
deadline ends the handshake for everyone (`F11`).
- **`bin/ambassyctl`** — both processes installed and driven as background services, LaunchAgents
on macOS and `systemd --user` units on Linux.
- **`docs/`** — architecture, both protocols, the permission model, configuration, logging,
testing, troubleshooting, and the repository audit, published to GitHub Pages by
`.github/workflows/docs.yml` through the same Jekyll that Pages runs anyway.
- **A test suite** — `yarn test` runs 288 vitest cases over the paths that are hard to provoke by
hand: a cancel that arrives before the session exists, a log that rotates mid-record, a stream
that stops before its task reaches a state. Nothing is mocked at the module level; every
substitution goes through an argument, so the seams stay visible in the production code.
[docs/testing.md](docs/testing.md) says what they reach and what they leave alone.
- **`yarn typecheck` and CI.** There is no build step, so nothing checked types unless asked; the
script carries the compiler flags in place of the `tsconfig.json` this repository deliberately
lacks, and `.github/workflows/ci.yml` runs it with the tests on every push and pull request.

### Fixed

Found by the repository audit and closed before this version went out.

- **Artifacts aggregate instead of concatenating (`F12`).** Two replacements of one artifact,
`old` then `new`, produced `oldnew`, and artifacts that were never the same artifact merged.
They are now keyed by `artifactId`, honour `append`, are seeded from the task snapshot, and keep
every data part.
- **A stream that loses its ending is an error (`F13`).** One that died mid-turn came back as a
tidy result saying the agent "finished in `TASK_STATE_WORKING`". A turn now reports an outcome —
`task`, `message`, `interrupted` or `truncated` — as well as a state.
- **The agent's last word survives (`F14`).** A refusal from the ACP bridge travels in a status
message and nowhere else, and it was dropped for everything but `INPUT_REQUIRED`. It is kept now
and reported by `a2a_ask` and `a2a_task`; `a2a_cancel` renders like a read rather than discarding
what the task had already produced.
- **Leaving plan mode is not a refusal.** The `switch_mode` rule denied Claude's `ExitPlanMode`,
which the adapter turns into an interrupt and reports as a cancellation — so a task that planned
before acting would have died as it was ready to start. The destination is in the option ids,
never in `rawInput`, and is read from an explicit table of both adapters' effects.
- **A deadline that is not a number falls back.** `Number(process.env.…)` of a typo is `NaN`, and
`AbortSignal.timeout(NaN)` throws rather than ignoring it, which took out every call the
deadline was meant to protect.
- **`..notes` is a filename (`F22`).** The containment check refused any relative path beginning
with two dots, which is a climb out only when the next character is a separator.
- **17 type errors in running code.** Found by the new type check: stream payload fields read as
though a protobuf message field were ever required, an exhausted union asked for its `$case`,
adapters declared with three pipes and spawned with two.
- **`ACP_CWD` must be a directory**, not merely a path that exists.

### Security

- **A conversation's sandbox is not named after the caller (`F01`).** The A2A `contextId` is text
the caller chooses, and joining it onto `.acp-sandboxes/` made `contextId: ".."` resolve to the
repository root — returned as `owned: true`, the flag that lets the classifier pass `execute`
and `fetch`. Directories come from `mkdtemp`, which fails unless the directory is new, with an
explicit context-to-directory map and a containment check on reuse.
- **A session that cannot be supervised is not used (`F08`).** When the supervised mode was
unavailable, the bridge logged the miss and carried on in whatever mode the adapter chose — for
both backends, one where it approves its own tool calls. It now fails the session and disposes
the half-started adapter. `switch_mode` is its own classifier case, decided on the option the
adapter offered rather than on who owns the directory.
- **The wire-tap binds loopback (`F02`).** It called `listen(PORT)` with no host, which opened a
wider door than the unauthenticated agent it fronts. It reads `HOST` now, like the two servers.

[0.1.0]: https://github.com/Flopsstuff/ambassy/commits/main
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,15 @@ The official samples worth reading first — the SDK's own README points at them
`authentication` (Bearer/JWT via Passport), `verify-signing` (signed cards, JWS + JWKS),
and `extensions` (protocol extensions).

Specification: https://a2a-protocol.org/v1.0.0/specification/
Specifications, one per protocol this repository speaks:

- A2A v1.0.0 — <https://a2a-protocol.org/v1.0.0/specification/>
- ACP — <https://agentclientprotocol.com/protocol/overview>
- MCP `2025-06-18` — <https://modelcontextprotocol.io/specification/2025-06-18>

## Changes

What changed and why, release by release: [CHANGELOG.md](CHANGELOG.md).

## License

Expand Down
16 changes: 9 additions & 7 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ A2A interface, but answered by a real coding agent over **ACP** instead of by a

Three protocols meet here, and the first two are easy to confuse because both are JSON-RPC:

- **A2A** (Agent2Agent) is horizontal — one agent delegates a *task* to another over HTTP. The
task is addressable, has a lifecycle, and survives the call that created it.
- **ACP** (Agent Client Protocol) is what an editor speaks to a coding agent over stdin/stdout.
There the roles are reversed: the agent is a child process, and we are the client.

- **MCP** (Model Context Protocol) points the other way: it is how a calling agent reaches tools,
and it is what publishes this A2A agent to Claude Code as something callable.
- **[A2A](https://a2a-protocol.org/v1.0.0/specification/)** (Agent2Agent) is horizontal — one
agent delegates a *task* to another over HTTP. The task is addressable, has a lifecycle, and
survives the call that created it.
- **[ACP](https://agentclientprotocol.com/protocol/overview)** (Agent Client Protocol) is what an
editor speaks to a coding agent over stdin/stdout. There the roles are reversed: the agent is a
child process, and we are the client.
- **[MCP](https://modelcontextprotocol.io/specification/2025-06-18)** (Model Context Protocol)
points the other way: it is how a calling agent reaches tools, and it is what publishes this A2A
agent to Claude Code as something callable.

The bridge sits between them. Everything in these pages was checked against running code, not
inferred from specifications — where behaviour and documentation disagreed, the observed
Expand Down
13 changes: 13 additions & 0 deletions docs/_config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Jekyll's share of publishing docs/ — deliberately four settings and no site to maintain.
title: Ambassy
description: A learning sandbox for the A2A protocol, and a bridge from it to a real coding agent.
theme: jekyll-theme-primer

# The documents link to each other as files — [permissions](permissions.md) — because they
# are read in the repository as often as on the web. This rewrites those links to the
# rendered pages, so one set of links works in both places.
plugins:
- jekyll-relative-links
relative_links:
enabled: true
collections: false
4 changes: 4 additions & 0 deletions docs/acp-bridge.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# The ACP bridge

Specification: <https://agentclientprotocol.com/protocol/overview>. The SDK is
`@agentclientprotocol/sdk` 1.4.0, and the protocol version it negotiates is `1` — two different
numbers that both travel in the same handshake, so it is worth knowing which is which.

`src/acp/agent.ts` is the A2A server from `src/agent.ts` with the placeholder executor replaced by
one that forwards the task to a real coding agent — `AcpExecutor`, in `src/acp/executor.ts`. The
backend is chosen by the launch command:
Expand Down
4 changes: 4 additions & 0 deletions docs/mcp-bridge.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# The MCP bridge

Specification: <https://modelcontextprotocol.io/specification/2025-06-18> — the version this
bridge answers `initialize` with. The SDK is `@modelcontextprotocol/server` 2.0.0, over
Streamable HTTP.

A third protocol joins the two others, and it points the other way. A2A and ACP are both about
reaching *down* to an agent that does work. MCP is how a calling agent — Claude Code, here —
reaches *out* to tools. This bridge publishes the A2A agent as four such tools, so calling it
Expand Down
Loading
Loading