@ab0t/acp
v0.1.0
Published
TypeScript/browser SDK for ACP (Agent Coordination Protocol): shared filesystem, ordered event log, mailbox, fencing leases, CRDT co-editing, presence — over the acp/1 wire.
Readme
@ab0t/acp — the ACP TypeScript SDK
The TypeScript/browser SDK for ACP (Agent Coordination Protocol): a shared
content-addressed filesystem, a totally-ordered event log, a directed mailbox,
fencing-token leases, CRDT co-editing (text + JSON), and live presence — as
ordinary async calls against a self-hosted coordd daemon, over the frozen
acp/1 wire.
- Zero runtime dependencies. Built-in
fetch+ web streams. Node ≥ 20 and evergreen browsers. - Same API shape as the Go SDK. Same verbs, same error taxonomy, same semantics (the SDK suite interface contract). Learn once.
- No Go toolchain, no source access needed — the SDK speaks the public wire.
Availability: published on npm —
npm install @ab0t/acp. It needs a runningcoordddaemon (self-hosted; 60 seconds to start, see below). TheacpCLI and theacp-mcpbridge are the other public client surfaces; this SDK is the programmatic path (Node + browser).
Quickstart (≤ 5 minutes)
1. Run a daemon (60 seconds — one static binary or Docker):
coordd -data ./acp-data -token dev-token
# or: docker run -p 8443:8443 ab0tcom/acp
# prints: clients: --server https://<host>:8443 --cert ./acp-data/cert.pem2. Install the SDK into your project:
npm install @ab0t/acp3. Connect and coordinate (demo.mjs):
import { Client, APIError } from "@ab0t/acp";
const c = new Client({
baseUrl: "https://localhost:8443",
token: "dev-token",
agent: "demo-1",
});
await c.health(); // reachable?
// The event log: append a fact, read it back.
const ev = await c.append({ action: "demo.start", entity: "run/1" });
console.log("appended seq", ev.seq);
// The shared filesystem: content-addressed blob + CAS commit.
const { hash, size } = await c.putBlob("# hello from TypeScript\n");
const m = await c.manifest();
try {
await c.commit({
base_version: m.version,
changes: [{ path: "docs/hello.md", hash, size }],
note: "first commit",
});
} catch (err) {
if (err instanceof APIError && err.conflict()) {
// someone committed first — re-read the manifest, rebase, retry
} else throw err;
}
console.log("committed docs/hello.md");
// The mailbox: a directed message to another agent.
await c.send({ to: "demo-2", type: "inform", subject: "hello", body: "file is up" });
// Follow the log live (Ctrl-C to stop).
await c.follow(ev.seq + 1, (e) => console.log(`#${e.seq} ${e.actor} ${e.action}`));4. Run it (the daemon's cert is self-signed, so hand it to Node):
NODE_EXTRA_CA_CERTS=./acp-data/cert.pem node demo.mjsThat's the whole loop: facts on an ordered log, files under CAS, messages, and a live stream — one daemon, no other services.
The primitives (when to use what)
| You want to record… | Use |
|---|---|
| a fact / audit trail (correctness) | the event log — append, follow |
| mutual exclusion across machines | a lease — acquireLease (the returned token is your fencing token; lease file:<path> to gate commits to that path) |
| a directed handoff to one agent | the mailbox — send / inbox / ack (threads are keyed by the thread_id you set) |
| an artifact (a file) | blobs + a commit — putBlob → commit (CAS; 409 ⇒ rebase on err.current, retry) |
| a live co-edited document | a CRDT doc — RGA + pushCRDTOps/pullCRDTOps (text), pushCRDTJSONOps/crdtJSONDoc (JSON) |
| an ephemeral hint ("what I'm doing now") | awareness — setAwareness / followAwareness (lossy; never correctness) |
Errors
Server-side failures reject with APIError:
try {
await c.acquireLease("build:main", 30);
} catch (err) {
if (err instanceof APIError) {
if (err.conflict()) { /* 409 — contended: err.current is the holding lease */ }
if (err.locked()) { /* 423 — a write gated by another holder's lease */ }
if (err.overQuota()) { /* 507 — persistent quota; do NOT blind-retry */ }
// err.status has the raw code (429 = transient, back off and retry)
}
}Co-editing text (CRDT)
import { RGA } from "@ab0t/acp";
const doc = new RGA("replica-A"); // stable, unique per client
const pulled = await c.pullCRDTOps("notes.txt", 0);
for (const op of pulled.ops) doc.apply(op); // fold in peers' ops
const ops = doc.generateOps(doc.text() + "\nmy new line");
await c.pushCRDTOps("notes.txt", ops, pulled.epoch);
// a 409 with a new epoch means the doc was compacted: rebuild from pullCRDTOps(doc, 0)Two replicas (in any language) and the daemon converge on identical text — that cross-language convergence is pinned by the SDK gate.
Browser bundles (plain HTML pages, no build step)
npm run build also emits one-file browser bundles under dist/browser/:
<!-- ES module -->
<script type="module">
import { Client, RGA } from "/dist/browser/acp.esm.js";
</script>
<!-- or a plain script tag: the SDK as a global -->
<script src="/dist/browser/acp.global.js"></script>
<script> const c = new ACP.Client({ baseUrl, token, agent: "page-1" }); </script>Both are dependency-free, ES2022, sourcemapped. (Remember the browser rules below: tokenless pages behind a credential broker.)
Agent helpers (the two loops every agent hand-rolls)
// Follow the log FOREVER: auto-reconnect, resume from lastSeq+1, backoff.
const stop = new AbortController();
await c.followForever(0, (e) => handle(e), {
filter: new EventFilter({ actions: ["task.*"] }),
signal: stop.signal,
});
// Hold a lease SAFELY: waits out contention, renews at ~TTL/3, releases;
// `lost` fires if a renewal fails — stop side-effecting immediately.
await c.withLease("build:main", 30, async (lease, lost) => {
// lease.token is your fencing token — present it on protected writes
await doTheWork({ signal: lost });
});The examples: collab-docs + collab-notebook
examples/collab-docs/— a collaborative document: two browser tabs typing into one doc, conflict-free, live presence carets — thennode agent.mjsand an agent co-author types alongside you. Uses the ESM bundle.examples/collab-notebook/— a Colab-style shared notebook: JSON-CRDT cell structure + a text-CRDT per cell, per-cell presence, and a local-only "Run" (code executes in YOUR tab — the daemon never runs code; outputs are shared as data). Its agent adds and fills cells next to yours. Uses the<script>-tag global bundle.examples/serve.mjs— the shared static server + credential broker both demos run behind (tokenless pages).examples/lib/— the reusable bindings (TextDocSync,Presence) that show the recommended sync-loop shapes.
Browsers, tokens, and TLS (read this before shipping a web page)
- Do not put a space-wide writer token in an untrusted page. Today's tokens scope by role/space/path-prefix — per-end-user read-scoping is a daemon roadmap item. Until then, browser deployments should be trusted surfaces (internal tools, kiosks) or fronted by a gateway that holds the credential and proxies (the page stays tokenless).
- TLS: browsers cannot pin a self-signed daemon cert. Use a real
certificate on
coordd, or terminate TLS at your gateway/reverse proxy. - WebSocket auth:
coorddauthenticates the awareness WebSocket upgrade via theAuthorizationheader, which browsers cannot set on a WebSocket — browser pages use the HTTP awareness follow, or a credential-injecting gateway. In Node, pass a header-capable factory:
import WebSocket from "ws";
const c = new Client({
baseUrl, token, agent: "dash-1",
webSocket: (url, headers) => new WebSocket(url, { headers }),
});
const sock = await c.awarenessSocket({ onDelta: (d) => render(d) }); // needs coordd -awareness-ws
sock.set({ cursor: [12, 40] }, 30, "tab-1");- Clusters: a follower answers live awareness follows with a redirect to the leader; Node hops it automatically, browsers cannot (the SDK throws a clear error) — point browser clients at the leader or a gateway.
Testing
npm test # build + unit tests (CRDT convergence, matcher parity, transport)
../../hack/run-ts-sdk-gate.sh # the full acceptance gate against a real local coorddRelationship to the other ACP surfaces
acpCLI — humans and scripts; the same primitives as commands.acp-mcp— agents in MCP harnesses (Claude Code, Codex, …).- Go SDK (
pkg/client) — Go services; same interface contract as this SDK.
The SDK suite contract (verbs, naming rules, error taxonomy) lives in the
INTERFACE.md of the SDK-suite ticket; this package conforms to it.
