ai-crew-sync
Read this in Spanish.
Your team's AI agents, finally on the same page.
ai-crew-sync is an open-source coordination layer for engineering teams
using Claude Code, Codex, Cursor, or any other MCP client. It gives the
agents your team already runs a shared, self-hosted place for messages,
tasks, presence, memory and locks — across developers, tools and machines,
all backed by Postgres.
Two agents try to claim the same task. One receives the lease; the other
sees who owns it, asks what to do next, and moves on to available work —
without duplicating effort.
Why ai-crew-sync?
A coding agent works well on its own. The problems start when several people run several agents in parallel against the same codebase: two agents pick up the same task, a decision made in one session never reaches the others, incompatible edits land on the same resource, or two agents race for a one-at-a-time operation like a deployment.
ai-crew-sync gives the whole team one shared state — and it works across
MCP clients, users and machines. Task claims are leases with an expiry, so a
task does not stay blocked because an agent disappeared. Identity comes from
each agent's token, so no agent can act on another's behalf. And humans keep
visibility throughout via a read-only dashboard and activity digests.
ai-crew-syncdoes not launch or replace your coding agents. It lets the agents your team already uses coordinate safely.
What agents can coordinate
Each agent — yours, each teammate's — connects with its own token and can:
Design decisions:
- Identity comes from the token, never from an argument: an agent cannot speak on behalf of another.
- Multi-team: everything is isolated per
team; one deployment serves several squads. - Stateless: MCP Streamable HTTP without transport sessions. Session labels and authenticated windows are application state in Postgres, so any replica serves any request and the bus scales horizontally behind any load balancer.
- Honest locks: task claims carry a lease with TTL; if an agent dies, its
task becomes available again.
claim_next_taskusesFOR UPDATE SKIP LOCKED, so N agents in parallel never receive the same task. - Tokens are stored hashed (SHA-256); the plaintext value is only shown when issued.
Install
# macOS / Linux, via Homebrew
brew install joaquinbejar/tap/ai-crew-sync
# Debian / Ubuntu (swap amd64 for arm64 on ARM machines)
curl -LO https://github.com/joaquinbejar/ai-crew-sync/releases/latest/download/ai-crew-sync_amd64.deb
sudo dpkg -i ai-crew-sync_amd64.deb
# RHEL / Rocky / Fedora (or ai-crew-sync.aarch64.rpm)
sudo rpm -i https://github.com/joaquinbejar/ai-crew-sync/releases/latest/download/ai-crew-sync.x86_64.rpm
# From source, or as a container
cargo install ai-crew-sync
docker pull ghcr.io/joaquinbejar/ai-crew-sync:latest
One binary is the server, the operator CLI (provisioning next to Postgres,
plus admin for remote administration), the console client and the
per-conversation MCP proxy (ai-crew-sync mcp proxy) that the plugin and the
recommended client configurations start — so install it on each developer's
machine as well as on the server. The .deb and .rpm additionally install
a hardened systemd unit and an environment file at
/etc/ai-crew-sync/ai-crew-sync.env, readable only by root and the service's
group (root:ai-crew-sync, 0640) — the service is
installed disabled, because it cannot work until DATABASE_URL points at
a reachable Postgres:
sudo vi /etc/ai-crew-sync/ai-crew-sync.env # DATABASE_URL, BUS_DASHBOARD_SECRET
sudo systemctl enable --now ai-crew-sync
Linux binaries are statically linked against musl, so they run on any distribution regardless of its glibc. Every package is installed and executed inside the distribution it targets before a release publishes it.
Windows is supported as a client (CLI, console client, mcp proxy).
Each release ships ai-crew-sync-<tag>-x86_64-pc-windows-msvc.zip and its
.sha256 on the releases page:
extract ai-crew-sync.exe somewhere on your PATH; it needs no Rust or
Visual C++ toolchain. Building from source (cargo install ai-crew-sync)
needs the MSVC Build Tools. The
configuration directory is %USERPROFILE%\.config\ai-crew-sync unless
BUS_CONFIG_DIR says otherwise. context codex-wake is not available
there, because the Codex app-server listens on a Unix socket. The plugin's
hooks need a POSIX shell (Git Bash) and Python; a python3 that is only the
Microsoft Store alias is detected and skipped in favour of python. Hosts
that load the plugin's hooks/hooks.json (Claude Code, Devin) run each hook
as sh "<plugin root>/scripts/<hook>.sh", so Git Bash's sh must be on the
PATH.
Quick start (docker-compose)
make up # creates the `edge` network if missing, then:
# docker compose --project-directory . -f Docker/docker-compose.yml up -d --no-build
Every variable has a sane default; override via the environment or
./.env (start from .env.example, which documents every knob with its
default — set a real POSTGRES_PASSWORD for anything not local). The
--project-directory . is what makes compose read ./.env; run compose
commands from the repository root with it, or use the make targets. make up-dev builds from the checkout instead. Docker Swarm works with the same
file:
export POSTGRES_PASSWORD=... # Swarm does not read .env files
docker network create --driver overlay --attachable edge # the bus always joins it, Traefik or not
docker stack deploy -c Docker/docker-compose.yml crew
(make deploy does the same after a preflight that also needs the production
variables below. TRAEFIK_NETWORK names an existing overlay instead of
edge.) The bus is stateless — scale bus replicas freely behind the
routing mesh.
The server listens on 0.0.0.0:8787 (BUS_BIND), migrates the database on
startup (BUS_AUTO_MIGRATE, on by default) and exposes:
POST /mcp— the MCP endpoint. RequiresAuthorization: Bearerwith an agent token (acs_…) or a session credential (acss_…, whichmcp proxysends after registering its window). Administrativeacsa_credentials are refused here.GET /health— for the load balancer.503only when the database is down; otherwise200withstatusok, ordegradedwhen the event listener has stopped hearing itself (events.listener). With a broker configured it addsbroker(configured;/health?broker=checkactually probes it) and thepublicationoutbox backlog.GET /dashboard— read-only panel for humans (presence, tasks, locks, latest channel messages and recently updated notes; DMs and conversations never appear). Auto-refreshes every 15s. Open it in a browser and paste an agent token once:POST /dashboard/loginexchanges it for a short-lived, HttpOnly, read-only session cookie that cannot call MCP tools. Scripts can skip the exchange and sendAuthorization: Bearer acs_...directly. Tokens are never accepted in the query string — a URL ends up in history, referrers and proxy logs./admin/*— the administration API, for administrativeacsa_credentials only; theai-crew-sync admincommands talk to it (see Remote administration).
Next: create the first administrative credential — the one step that runs next to Postgres.
Deploying to production
There is a single compose file, and it has a working default for everything
so make up boots on a laptop. What keeps production safe is the preflight
that make deploy runs before touching the cluster:
export POSTGRES_PASSWORD=… # not the example value
export BUS_VERSION=0.7.0 # a full release version, never `latest`
export BUS_ALLOWED_HOSTS=crew.example.com
export BUS_DASHBOARD_SECRET=… # shared, so sessions work across replicas
make deploy # preflight, then docker stack deploy
make deploy refuses if any of those is unset, if the password is still
change-me, if BUS_VERSION is latest, or if TRAEFIK_ENABLE=true is set
without BUS_PUBLIC_HOST; it also refuses when the proxy network
(TRAEFIK_NETWORK, default edge) does not exist on the swarm. make deploy-check runs the variable checks alone and never contacts the cluster.
Pin a full version such as 0.7.10: each release also publishes a moving
0.7 tag, and the preflight does not catch it.
On a swarm of more than one node, pin the stateful services: the database
and broker volumes are local to the node that holds them, and an unpinned
service is rescheduled after a reboot onto whichever node returns first,
where it starts on an empty volume (every token then reads as revoked). Set
POSTGRES_PLACEMENT="node.hostname == <node>" and, with JetStream on,
NATS_PLACEMENT in the stack environment; unset means no pin.
Behind a Traefik v3 proxy (--providers.swarm) that already terminates TLS,
set TRAEFIK_ENABLE=true and BUS_PUBLIC_HOST=crew.example.com (plus
TRAEFIK_NETWORK/TRAEFIK_ENTRYPOINT/TRAEFIK_CERTRESOLVER if they differ
from edge/websecure/le): the bus carries the router labels. It joins the
proxy's network in every deployment, Traefik or not — only the labels depend
on TRAEFIK_ENABLE — and the proxy's stack must have created that network as
an attachable overlay. Traefik forwards the original Host header, so
BUS_ALLOWED_HOSTS must include BUS_PUBLIC_HOST (or be * when the proxy
already validates Host); otherwise every /mcp call through it is refused
with 403.
Onboard the team
Administration runs from your own machine with an administrative
credential (prefix acsa_). Only the first one has to be minted next to
Postgres, because nothing exists yet that could authorise a remote request.
Three steps, once per deployment:
1. Bootstrap the first administrative credential
admin bootstrap needs DATABASE_URL and a migrated database — the server
migrates on startup; otherwise run ai-crew-sync migrate first. No team has
to exist yet. Run it wherever DATABASE_URL is at hand:
# Compose stack, from the repository root: the bus container has DATABASE_URL
docker compose --project-directory . -f Docker/docker-compose.yml exec bus \
ai-crew-sync admin bootstrap --label "joaquin laptop"
# Docker Swarm (stack `crew`), on a node that runs a bus replica
docker exec -it $(docker ps -q -f name=crew_bus | head -n1) \
ai-crew-sync admin bootstrap --label "joaquin laptop"
# .deb / .rpm: DATABASE_URL lives in the service's environment file
sudo sh -c 'set -a; . /etc/ai-crew-sync/ai-crew-sync.env; exec ai-crew-sync admin bootstrap --label "joaquin laptop"'
# Anywhere else that can reach the database
DATABASE_URL=postgres://bus:…@db-host:5432/bus ai-crew-sync admin bootstrap --label "joaquin laptop"
Global administrative credential — shown once, store it now:
acsa_3f9c…
Use it from your machine with `ai-crew-sync admin login --url <bus>`.
1 global credential(s) are now active; list them with `admin credential list`.
The secret is printed once; the bus keeps only its SHA-256. bootstrap never
refuses because a credential already exists — every run mints one more global
credential — which also makes it the way back in when the last one is lost
(see Recovery). The label is a
note for humans.
An administrative credential is a different class from an agent token
(acs_): it identifies nobody on the bus, cannot post, claim or read
anything, and only manages teams, agents, agent tokens and administrative
credentials. Agent tokens, in turn, can never mint administrative credentials
or other agent tokens, not even for their own agent — the only thing they
derive is a session credential for one of their own windows
(register_session). Every issue, grant and revocation lands in an audit
table that never holds a secret.
2. Log in from your machine
ai-crew-sync admin login --url https://crew.example.com # hidden prompt for the acsa_… secret
ai-crew-sync admin whoami # which credential, and what it may administer
login verifies the credential against the bus before saving it (mode 0600,
in the configuration directory). From here on nothing needs SSH, docker exec or a database connection.
3. Create the team, its agents and their tokens
ai-crew-sync admin team add --slug acme --name "Acme Squad"
ai-crew-sync admin agent add --team acme --name joaquin
ai-crew-sync admin agent add --team acme --name marta
ai-crew-sync admin token issue --team acme --agent joaquin --label "joaquin laptop" # printed once
ai-crew-sync admin token issue --team acme --agent marta --label "marta laptop"
Every token is shown only once, and only after the bus has confirmed that
it authenticates as exactly that agent. Two ways to avoid handing tokens
around: admin token issue … --save --repo <entry> writes it straight into
tokens-<team> on the machine that runs it and never prints it, and admin grant --team acme gives a teammate an administrative credential limited to
that team, so they can mint their own. Remote
administration has the full command set.
Without the remote API. Next to Postgres, the same can be done with the
local operator commands, which need DATABASE_URL and no credential:
ai-crew-sync team create --slug acme --name "Acme Squad"
ai-crew-sync agent add --team acme --name joaquin # always mints a token and prints it once
ai-crew-sync agent add --team acme --name marta
Later management: team list, agent list, agent disable (running agent add again re-enables a disabled agent, with a new token), token issue,
token list, token revoke. All of them except agent disable also exist
remotely as admin team|agent|token …. Every command is listed in the
command-line reference.
One agent per tool, not per person
If you run Claude Code and Codex — or any two coding agents — give each its own agent:
ai-crew-sync admin agent add --team acme --name joaquin # Claude Code
ai-crew-sync admin agent add --team acme --name joaquin-codex # Codex
Sharing one token between two tools makes them the same agent to the bus.
Through mcp proxy (or with distinct X-Crew-Session labels) each window
still gets its own session, so claims, locks, presence and read cursors stay
apart, and a sibling window's claim is refused ("claimed by your own …
session"). What you lose is identity: both tools are one name in whoami,
list_agents and team_digest, a direct message to bare joaquin reaches
both, revoking or disabling one cuts off both, and teammates cannot tell which
tool answered. Two clients that send no session at all share one, and then
the coordination silently stops working between them: both claim the same
task and both are told they hold it, one releases the other's lock, and
reading in one marks the other's messages read.
With separate agents all of that works as designed, revoking one tool's access
does not touch the other, and they can talk to each other: ask_agent from
Claude Code to joaquin-codex behaves exactly like asking a teammate.
File conflicts are not the bus's problem. Two agents editing the same files at once will fight whatever the bus says. Claims and locks are the tools for that, and someone has to use them — or give each agent its own branch or worktree.
Channels, task keys and lock names
One channel per repository plus one for the team:
create_channel market-data
create_channel core-manager
create_channel general
A channel becomes a window's default when it is named after the window's
project (context set-project, which defaults to the directory name) or is
named outright with --channel / configure_session — see The session's
channel below.
Two conventions that matter as soon as a team has more than one repository:
- Task keys are unique per team, not per repository.
issue-151collides the moment two repositories both have one, so prefix them:market-data#42,core-manager#151. - Lock names are team-wide too.
deployheld for one repository blocks the other's deploy; usemarket-data:deploy.
Connect each agent
Any MCP client works: the bus is plain Streamable HTTP with a Bearer token. Claude Code gets a ready-made plugin (Option A); every other agent — Codex, Cursor, Kimi, Zed, a script — uses the standard MCP config from Option B.
Option A (Claude Code): plugin
This repo is also a Claude Code plugin marketplace. Each teammate installs
the binary once (Homebrew, .deb/.rpm or cargo install, see Install),
tells it which bus they are on, and then installs the plugin inside Claude
Code:
# --url is the bus's base URL; /mcp is appended for you
ai-crew-sync context profile add --name acme --default \
--url https://crew.example.com --team acme --agent joaquin
ai-crew-sync context show # endpoint, profile, expected identity, token prefix
ai-crew-sync context verify # asks the bus who the token REALLY is
The profile reads its credentials from tokens-acme in the teammate's own
configuration directory (~/.config/ai-crew-sync by default). Either the
teammate fills it on their machine — with a team-scoped administrative
credential (admin grant --team acme), admin token issue --team acme --agent joaquin --save --repo <entry> writes the token there and never prints it — or
an administrator issues it without --save and hands the printed token over.
<entry> is the project's name (context set-project, by default the
directory name), a key from .acs.toml or the profile, or _base.
/plugin marketplace add joaquinbejar/ai-crew-sync # or your fork
/plugin install ai-crew-sync@ai-crew-sync
The plugin needs ai-crew-sync on the PATH, and its hooks also need
python3 (without it the MCP tools and presence still work; the session-start
summary and the question drain silently do nothing). Its MCP entry is not an HTTP
URL with a token in it: it starts ai-crew-sync mcp proxy, one process per
conversation, and that process resolves a credential from your local profiles
and registers the window as an authenticated session. Nothing in the plugin
config, and nothing in your shell, has to hold a token. A teammate who would
rather keep the old environment pair can: mcp proxy reads BUS_URL and
BUS_TOKEN too. Mind the precedence, which is the resolver's and not the
plugin's: an exported BUS_TOKEN (like --token) wins over the
repository's .acs.toml profile and the user default, and given together
with --profile it is an error rather than a silent choice. Migrating from a
shell wrapper that exports the token per directory means unsetting it (or
retiring the wrapper) wherever the profile should decide; while it is
exported, the profile never applies.
The plugin comes fully preconfigured:
- MCP
ai-crew-syncover the per-conversation proxy (no JSON editing by hand, no credential in the config). Each Claude Code window becomes its ownagent/sessionaddress, proven by a credential of its own; two windows in the same repository can no longer be mistaken for each other. - Hooks: on session start it heartbeats and injects a team summary into
Claude (unread DMs, own tasks,
team_digestof the last 8h — configurable withBUS_DIGEST_HOURS); after each response it renews presence with the checkout's repo/branch and reports the modelidle, when a prompt is submitted it reports the modelattending, and on session end it marksidle. With no bus configured at all, they do nothing. - Waking an idle window: a window parked at the prompt calls no tools, so
a message for it used to wait until its human typed. From session start and
after every response, a background hook (
watch.sh, Claude Code'sasync+asyncRewake) waits on the bus as that window and wakes it when something new is addressed to it: unread direct messages, or conversation messages for that window. The notice carries counts, never bodies; the same unread messages never wake a window twice, a newer watcher replaces the older one, and it gives up quietly afterBUS_WATCH_SECS(default 14000 s, under the hook's 4 h timeout). Authenticated mode only (it needs the binary and the window's own credential). Codex has no such hook; for it there is an opt-in adapter (see "Waking an idle Codex thread" below).
You no longer set BUS_SESSION for this: the proxy derives the session from
the conversation id Claude Code gives it, so a window keeps its identity across
a reconnect, a fork gets a new one, and presence, claims and locks separate
themselves. What is worth setting per repository is the project and the
role, so teammates can find the right window:
ai-crew-sync context set-project --profile acme --project market-data --channel market-data
Run it from the repository root (or pass --dir). It writes .acs.toml: the
profile name, the project label (by default the directory name; also the
tokens-<team> entry the credential is read from, unless --key names
another) and the default channel — never a URL or a credential, so it can be
committed. With the plugin, set the window's role at runtime with the proxy's
local configure_session tool; ai-crew-sync proxy-config --role review is
for a hand-written client configuration (Option B).
The Stop hook additionally drains questions: when a teammate's agent is
blocked on ask_agent, the session is held open long enough to answer before
it goes quiet — the longest-waiting question first, one per turn, and never one
you have already replied to. The drain alone does not make an idle session
answerable: a coding agent only calls tools while it is processing a turn.
A window parked at the prompt answers only if something starts a new turn:
the background watcher below (Claude Code), the opt-in adapter (Codex), or
its human. list_sessions shows which applies (attention.wake), and the
capability matrix in docs/acceptance/host-integration.md says what was
verified on which host version. For anything that must not wait, use a task
or a channel message.
- Commands:
/ai-crew-sync:standup [hours],/ai-crew-sync:catchup [hours],/ai-crew-sync:announce [#channel] message,/ai-crew-sync:ask <agent> <question>,/ai-crew-sync:claim <key|next>,/ai-crew-sync:done <key> [result],/ai-crew-sync:handoff <key> <agent[/session]>,/ai-crew-sync:board,/ai-crew-sync:who,/ai-crew-sync:lock <resource>,/ai-crew-sync:unlock [resource],/ai-crew-sync:note <key> [text],/ai-crew-sync:wait [kinds],/ai-crew-sync:thread <addresses> -- <title>,/ai-crew-sync:inboxand/ai-crew-sync:review <agent> <PR>. Each is a written procedure over the bus tools: the same three or four calls in the same order, with the same guardrails, from every window.threadandinboxneed the team's conversations capability (ai-crew-sync team capability --team <team> --conversations on, next to Postgres). - Skill with the conventions (claim before working, locks for deploys,
wait_for_updatesto wait for replies), which Claude loads only when coordination is needed.
The same procedures for any other host
The commands are generated from recipes/, one Markdown file per procedure
written for an agent that has the bus tools and nothing else: no frontmatter,
no host assumptions, {{input}} where the caller's arguments go. For Codex,
add a line to your own repository's AGENTS.md pointing at them (ai-crew-sync recipes <name>; examples/CLAUDE.md-snippet.md has ready wording). Kimi
Code, Grok or any MCP client can be handed one verbatim, and a machine
with the binary and not the repository gets them from
ai-crew-sync recipes (list) and ai-crew-sync recipes catchup (one).
make recipes regenerates the slash commands and the skill's procedure
references (plugin/skills/ai-crew-sync/references/, for hosts without slash
commands, such as Antigravity) and removes any left behind by a deleted
recipe; make check fails when either drifts from its recipe, and the unit
tests check the commands, so a procedure is edited in one place only.
The hooks pick one of three paths per conversation, tried in this order:
- Authenticated — the window has a proxy, so the hooks call
ai-crew-sync context hook, which reads that window's private binding and acts as that session. The credential never passes through a script, an argument or an environment variable. This needs the binary on the PATH, which the plugin already requires. A bound window stays bound whatever the environment says: an exportedBUS_TOKEN/BUS_SESSIONdoes not redirect its Stop drain to another session's inbox. The binary serves hooks only the four tools their scripts use (whoami,read_messages,team_digest,heartbeat), so a hook can never issue, rotate or revoke a credential. Only a conversation with no binding at all takes the paths below. - Local profiles — no usable binding and no exported
BUS_TOKEN, but the binary is on the PATH. The hooks runai-crew-sync client --json callwith credentials fromprofiles.tomland.acs.toml, and the same session label the conversation's proxy derives from its id (a label, not proof). - Legacy —
BUS_URL/BUS_TOKENexported. The hooks fall back to plaincurl+python3and theX-Crew-Sessionlabel, exactly as before. Nothing else is assumed to exist.
None configured means the hooks do nothing at all, silently.
Option C (Google Antigravity): plugin
The same plugin/ directory is also an Antigravity plugin. Antigravity reads
its own files there:
plugin.json: the manifest;mcp_config.json: startsai-crew-sync mcp proxy, with no credential;- the root
hooks.json: SessionStart and Stop, with timeouts in seconds; skills/ai-crew-sync/: the conventions skill, plus one reference per procedure, generated fromrecipes/.
Claude Code keeps reading .claude-plugin/, .mcp.json, hooks/hooks.json
and commands/. Neither host loads the other's files.
brew install joaquinbejar/tap/ai-crew-sync # the binary, as for Claude Code
ai-crew-sync context profile add --name acme --default \
--url https://bus.your-company.com:8443 --team roundcrew --agent backend-agy
git clone https://github.com/joaquinbejar/ai-crew-sync
agy plugin install ai-crew-sync/plugin # or copy it to <workspace>/.agents/plugins/
Give Antigravity its own agent, as for any other tool (see "One agent per
tool"). What was verified with agy 1.4.3:
- Install.
agy plugin installinstalls the MCP server, the hooks and the skill with its procedure references. - Conversation identity. Each conversation binds its own session from
the id Antigravity sends on every tool call (
binding: antigravityinsession_status). - SessionStart. It injects the window's identity and the team catch-up.
- Stop. It keeps the loop going to answer a waiting question.
Antigravity has no background hooks, so an idle conversation is not woken.
Project defaults with a global install. A plugin installed with
agy plugin install starts the proxy in the plugin's directory, not in your
workspace. When no .acs.toml is found from there, the proxy asks the client
for its MCP roots (roots/list, which Antigravity answers). It then uses the
first file:// root that is inside a project with a .acs.toml, so the
workspace's profile, project and channel apply with no user default.
session_status reports project_root_from: mcp-roots. A root never
overrides --project-dir, a profile from --profile / BUS_PROFILE, or a
.acs.toml the working directory found. Hosts without roots (Codex)
behave as before.
Without the plugin, register the server in ~/.gemini/config/mcp_config.json
(see examples/antigravity-mcp_config.json). ai-crew-sync proxy-config
prints the same mcpServers block. This gives the tools, not the hooks.
Option B (any MCP client): manual configuration
Two shapes, and the difference is what proves which window is calling.
Through the proxy (recommended). The client starts the binary instead of
opening an HTTP connection; each conversation gets its own authenticated
session, and the client configuration holds no credential: the proxy
resolves the agent token from your local profile (or BUS_TOKEN/BUS_URL)
and keeps its session credential in a private 0600 file. Generate the
block:
ai-crew-sync proxy-config # .mcp.json shape (most clients)
ai-crew-sync proxy-config --format toml # ~/.codex/config.toml (Codex)
ai-crew-sync proxy-config --role review # windows here start as reviewers
proxy-config also takes --project and --profile, and mcp proxy itself
accepts --project, --role, --channel and --profile (the window's
starting labels and profile, over .acs.toml and the user default) plus
--project-dir and --host-session — the startup counterparts of
configure_session.
{
"mcpServers": {
"ai-crew-sync": {
"command": "ai-crew-sync",
"args": ["mcp", "proxy"]
}
}
}
Ready to copy: examples/.mcp.json, examples/codex-config.toml and
examples/antigravity-mcp_config.json.
Direct HTTP. No binary needed, and the bus is plain Streamable HTTP with a
Bearer token — this is what a script, a CI job or an MCP client you cannot
give a command to uses. Identity is the token; the window is a X-Crew-Session
label, which the bus trusts for partitioning presence and claims but never
as proof of who you are:
{
"mcpServers": {
"ai-crew-sync": {
"type": "http",
"url": "https://crew.example.com/mcp",
"headers": {
"Authorization": "Bearer ${TEAM_BUS_TOKEN}",
"X-Crew-Session": "market-data"
}
}
}
}
Ready to copy: examples/.mcp.http.json. You can also generate the block:
ai-crew-sync mcp-config --url https://crew.example.com/mcp \
--token acs_... --session market-data
mcp-config writes the token into the block literally (Bearer acs_…):
keep the generated file out of version control, or replace the value with an
environment reference such as ${TEAM_BUS_TOKEN} as above. Without --url
it points at http://localhost:8787/mcp.
With that, each agent sees the bus tools and uses them on its own. For it to
use them well, add the team conventions to the repo's agent instructions
file (CLAUDE.md, AGENTS.md or equivalent) — there is a ready-made snippet
in examples/CLAUDE.md-snippet.md.
Local profiles and project defaults (no BUS_TOKEN export)
The console client (and everything built on it) can find its credentials without a per-shell export or a wrapper function. Two local files do it:
- Profiles —
profiles.tomlin the configuration directory: which bus, expected team and agent, and which token file holds the credential (the sametokens-<team>filesadmin token issue --savewrites). No secret lives in the profile. - Project defaults —
.acs.tomlat the project root, local by default: names an approved profile, the logical project, and optionally a defaultchanneland a token-filekey. The profile it names lives in each person's ownprofiles.toml, socontext set-projectlists the file in the repository's.git/info/exclude(shared by linked worktrees) and warns when git already tracks it. Commit it only as a team decision, when every member has profiles with the same names. Nothing else: any other key is refused, andurl,endpoint,token,tokens,bearerorsecretare refused with an explicit error.
The configuration directory is $BUS_CONFIG_DIR when set, else
$XDG_CONFIG_HOME/ai-crew-sync, else ~/.config/ai-crew-sync. It holds
profiles.toml, the tokens-<team> files, the admin login file, and
sessions/ (0700), where each proxy keeps its conversation's binding and
session credential in a 0600 file named after a SHA-256 of the conversation
id.
ai-crew-sync context profile add --name acme --url https://crew.example.com \
--team acme --agent joaquin --tokens tokens-acme --default
ai-crew-sync context profile list
ai-crew-sync context profile default acme # or: --clear
ai-crew-sync context profile remove old-bus
cd ~/Repos/acme/market-data
ai-crew-sync context set-project --profile acme --project market-data --channel market-data
ai-crew-sync context show # endpoint, profile, token entry (prefix only), project
ai-crew-sync context verify # asks the bus: must be joaquin@acme, or it fails
ai-crew-sync client whoami # no BUS_TOKEN needed
The token entry is chosen in this order: key from .acs.toml (set-project --key), the project name, the profile's key (profile add --key), then
_base; --tokens defaults to tokens-<team>. Precedence between sources
is fixed, and context show prints which one won (explicit,
profile-flag, project-default or user-default):
--url / BUS_URL overrides the endpoint whichever source picks the
credential, a profile included: a leftover exported BUS_URL sends the
profile's token to that URL, so unset it when switching to profiles.
Precedence never moves, but it is no longer invisible: when a leftover
BUS_TOKEN or BUS_URL export outranks an installed profile, every command
prints a warning on stderr naming the shadowed profile (the proxy logs it,
keeping MCP stdout protocol-clean). context show and context verify
report where the endpoint and the credential each came from —
BUS_TOKEN (environment), --token (flag), or the token-file entry and the
profile that selected it — and verify repeats that provenance when the bus
refuses the token, so the error names the source instead of only the
symptom. The credential itself is never printed. Since 0.7.1 the handshake's
serverInfo carries ai-crew-sync and the server's real version (older
servers report the framework's), and the proxy logs a version-skew hint when
its binary and the bus differ — a hint to align them, not a refusal.
A profile that does not exist locally is an error, whatever named it: a
repository can suggest a profile, never define one, and .acs.toml is refused
outright if it carries url, token or a tokens path. Endpoints and
credential references come from your own profile store only, so a cloned
repository cannot send your token anywhere. Two windows in the same repository
select profiles independently (--profile), sharing nothing mutable. Writes to
the profile store are serialised through a lock and land atomically with mode
0600.
Authenticated sessions: proving which window you are
X-Crew-Session is a label the caller chooses. It is enough to keep one
person's windows from overwriting each other's presence and claims, and it is
not proof of anything: whoever holds the agent token can send any label.
register_session turns a window into something it can prove. Present your
agent token once per conversation, and the bus returns a credential derived
from it:
register_session {"session": "conv-7f2a"}
→ {"session_token": "acss_…", "session_id": "…", "session": "conv-7f2a",
"address": "joaquin/conv-7f2a", "epoch": 1,
"expires_at": "…", "expires_in_seconds": 86400}
Send that as the bearer token from then on. It authenticates as your agent, in that one session, and:
- derives everything from the parent. Agent and team come from the token that registered it, so a client cannot assert either;
- mints nothing. Not an agent token, not an administrative credential, not another session;
- expires on its own (24 hours by default,
ttl_secondsfor less) and dies with its parent: revoke the token or disable the agent and every session under it stops authenticating, with no sweep to wait for; - refuses a contradicting header. A request whose
X-Crew-Sessionnames a different window is rejected, so a proven session can never be widened into somebody else's; - is not handed to whoever holds the agent token. Registering a label
that is live is refused (a tool error starting
conflict: session '…' is already registered and still live): the only way back into a window is its own credential, throughresume_session, which issues a new secret, bumpsepochand leaves identity and history unchanged. A label whose credential has expired or been revoked can be registered again, as a new window under the old name; - fences what it replaces. Send the epoch as
X-Crew-Epochand a process that was resumed away is told so (409) instead of writing as the window that replaced it.
renew_session extends the credential you already hold without touching its
secret or epoch. revoke_session closes it, or another window of your own
agent by label. Revoking a session ends that credential, not the label: the
holder of the agent token can register the label again, because that token
already speaks for every window of the agent. To keep an agent off the bus, revoke its agent token or disable the
agent; both end its sessions too. whoami includes session_identity (session id, epoch,
registration and expiry) when the label was proven by a session credential,
and omits the field when the label is only a header.
Nothing here is required: an agent token with a session header keeps working
for every tool and for a bare curl, exactly as before.
One session per conversation: the stdio proxy
Sessions separate windows, but a header written once in a client config is
the same header in every window of that client. ai-crew-sync mcp proxy is a
local MCP server the client starts once per conversation — the norm for
stdio servers — so the process itself is the unit of isolation: it mints the
session, sends it on every forwarded call, and keeps that window's project
and role. It works with any MCP client; what a particular host offers is used
as an enrichment, never required.
{
"mcpServers": {
"ai-crew-sync": {
"command": "ai-crew-sync",
"args": ["mcp", "proxy", "--role", "implementation"]
}
}
}
No token and no URL in the client config: credentials come from your local
profile and the project's .acs.toml (see above). Every remote tool appears
as usual, plus two that never reach the bus:
session_status— verified agent and team, session id, address (agent/session), project, role, channel, andcredential_from: where the credential came from (environment, profile flag, the project's.acs.toml, or the user default). Never credentials.configure_session({role?, project?, channel?, profile?})— this window only. Role and project are metadata: the session id, cursors, claims and locks are untouched.profileswitches to another locally approved credential of the same team, verified withwhoamibefore anything changes; a failed verification leaves the old context intact, in-flight calls to the old identity are cancelled rather than replayed, and what it still holds (claims, locks) is reported, never transferred. A different team needs a new conversation, because switching credentials cannot erase what this conversation has already seen.
Long waits are one call. The bus answers a wait_for_updates within 55 s
(HTTP intermediaries cut longer requests), and a host that simply asks again
spends a model turn every minute it has nothing to do; an idle Codex session
burns tokens that way. Through the proxy, timeout_seconds may be up to
1800: the proxy keeps waiting on the bus in slices and answers once,
when something happens or the time is up. The host must allow a tool call
that long; for Codex, set tool_timeout_sec on the server (see
examples/codex-config.toml).
Nothing is lost between two waits either. Every wait_for_updates result
carries resume_from, its position on the bus; pass it back as since and
the next wait reports at once the task, lock and note changes that landed in
between (messages never needed it: their unread state is in the database).
The proxy chains it from one slice of a long wait to the next. The bus keeps
those events for about a day (bus_event_log, migration 0020).
Wait for what you will read. wait_for_updates takes scope with the same
values as read_messages: all (the default), inbox, or a channel name.
The backlog it reports is the one the matching read drains, read from the
same cursor, so a window that only reads its inbox waits with
scope: "inbox" and is no longer returned at once by unread channel
chatter it never reads. Only messages are narrowed; tasks, locks and notes
still wake the wait unless kinds leaves them out. Every result says which
scope it watched (message_scope), how much is unread there
(unread_in_scope) and which call consumes it (read_with). A wait never
moves a read cursor. pending_questions counts questions addressed to the
window (or to you) in the last 24 hours that you have not answered yet:
reading a question does not answer it, replying to the asker does, and a
pending question is reported, never a reason to wake on its own.
The proxy does this for you: it registers the session on connect, forwards
every call with the credential and the epoch, renews the credential half-way
through its lifetime whether the window is busy or idle (BUS_SESSION_TTL_SECS
and BUS_SESSION_RENEW_LEAD_SECS tune the lifetime it asks for and the lead),
and, when the window closes, stamps its binding as closed. The credential
itself stays in that 0600 file: it is the only way to resume the same
window after a restart of the conversation, since the bus refuses to hand a
live session to the agent token. A closed binding is unusable by hooks, and
the credential keeps only the lifetime the bus gave it; revoke_session
ends it early. A renewal the bus refuses is reported by session_status as
a rejected credential, never papered over with another identity. A bus too old to issue
credentials simply keeps the label-only connection.
When the stored credential no longer works on reconnect — expired, revoked,
or rotated by something this window never heard from — the proxy registers
the same label again with the agent token. The bus still decides: a label
that is still live under a secret this window does not hold is refused, and
the refusal says how to close it (revoke_session with its label, from
another window or ai-crew-sync client call). resume_session called
through the proxy is run by the proxy itself, which keeps the rotated
credential and returns its description without the secret. It takes no
ttl_seconds there: the credential keeps the lifetime the window renews it
with (BUS_SESSION_TTL_SECS). If the rotated credential cannot open a
connection, the proxy saves it, leaves the bus instead of serving on the
refused secret, and says so; restarting the MCP server reconnects with it.
Off the bus, the window is still the same identity: it cannot switch to
another team's profile, and on exit it only closes a binding that still holds
the credential it saved.
Conversation identity, in order: --host-session / BUS_HOST_SESSION
(any host that can set a per-window variable), CLAUDE_CODE_SESSION_ID
(Claude Code sets it on MCP server processes; honoured only for Claude Code's
own client, see below), ANTIGRAVITY_CONVERSATION_ID
(where an Antigravity surface exports it to MCP servers), the conversation
id a request carries in _meta (Antigravity's
antigravity.google/conversation_id, Codex's threadId), otherwise the
process itself. With a conversation id the session label is stable, so
a resumed conversation reconnects to the same session and a fork gets a new
one; without one, the session lives as long as the process. When the
conversation was identified from the request's _meta, a second
conversation id of the same kind on the same process is refused rather than
mixed in. When it was fixed by --host-session / BUS_HOST_SESSION,
CLAUDE_CODE_SESSION_ID or ANTIGRAVITY_CONVERSATION_ID, that binding wins
and other ids on the wire are ignored, so configure the host to start one
proxy per conversation. Verified with the Antigravity CLI (agy 1.4.3): it
exports ANTIGRAVITY_CONVERSATION_ID to hooks but not to MCP servers, and
sends the conversation id in _meta on every tools/call, so session_status
reports binding: antigravity and hooks of the same conversation find its
binding.
A host started from inside a Claude Code session (its terminal, or a tool it
runs) inherits CLAUDE_CODE_SESSION_ID, and so do the proxies that host
starts. The variable alone is therefore not trusted: the proxy waits until the
client names itself (clientInfo in initialize, or in a request's _meta)
and binds to that Claude conversation only when the client is Claude Code
(claude-code). Codex, Antigravity or any other client started there gets a
conversation of its own, and the parent window keeps its session, epoch and
binding record untouched.
The initialize result's instructions tell the model who and where this
window is (agent, team, session address, project, role, channel) plus the
bus's own conventions — every MCP client passes those on, so this needs no
hooks. The unread direct messages, open claims and team digest are added by a
session-start hook where the host has one; elsewhere the model calls whoami
and team_digest itself. Where a host does have lifecycle hooks, they run
ai-crew-sync context hook --binding <conversation id> --event <event>: the
helper reads the private record the proxy wrote (0700 directory, 0600 file),
acts as that window with its own credential, and prints only what the host
expects. The credential never reaches argv, stdout or a log, a hook never
registers (so it cannot fence its own proxy), and a conversation with no
binding produces no output at all rather than acting as a shared identity.
Like the proxy itself, this authenticated hook mode needs the ai-crew-sync
binary on the PATH; the legacy mode still needs only curl and python3.
Presence is the proxy's own: a heartbeat on connect with the repo and branch
of the project directory, a keep-alive every five minutes that renews the
lease without touching the status, idle on exit. Nothing is pushed into an idle turn: incoming messages are read with
read_messages or awaited with wait_for_updates, as with a direct
connection.
Connected is not attending
A live proxy proves that a process is running, not that a model is reading
the bus. A window whose turn has ended stays connected, and before 0.7.7 it
looked exactly like one at work. list_sessions, list_agents and the
dashboard now report each window's attention apart from online:
Nothing is inferred. attending and waiting stand for ten minutes without
a newer observation and only while the window is online; idle stands until
something observes a new turn; whatever nobody observed is unknown. The
proxy's keepalive renews the lease and the credential with refresh_only
and never touches status or attention, so an idle window stays idle. The
proxy cannot see a turn end: a host whose hooks are not installed shows
attending after a tool call, which turns unknown once it is old (Codex
runs the plugin's Stop and UserPromptSubmit hooks too when the plugin is
installed there). wake is
descriptive, never proof that a wake will succeed. None of these fields
promises an answer.
heartbeat takes the observations as optional arguments (attention,
tool_activity, wake, wake_ttl_seconds, wake_result, refresh_only);
omitted keeps, "" clears. They are additive: existing callers see only new
fields.
Antigravity's hook contract
The same scripts serve Google Antigravity, which passes its payload with a
camelCase conversationId and reads every hook's answer as JSON. Each
script tells the hosts apart by the payload (falling back to
ANTIGRAVITY_CONVERSATION_ID, which Antigravity exports to its hooks) and
answers in that host's contract:
- SessionStart:
{"injectSteps": [{"systemMessage": {"systemMessage": …}}]}instead ofhookSpecificOutput. - Stop drain:
{"decision": "continue", "reason": …}with the whole instruction in the reason, once per question, instead ofblock. Only after a turn that ended normally (terminationReason: NO_TOOL_CALL): a cancelled turn is never forced on, and its question waits for the next stop. - Anything with nothing to say:
{}.
Antigravity runs hooks from the plugin's directory, so the scripts move to
the payload's first workspacePaths entry before reading repo and branch.
ai-crew-sync context hook --host antigravity selects the format explicitly;
without it the helper detects Antigravity by its environment variable.
Verified with agy 1.4.3:
- SessionStart injected the window's context into a new conversation.
- A question pending for a resumed conversation was answered (
reply_toset) after the Stop drain continued the loop.
One limit comes from the host: agy names the conversation to MCP servers
only on tools/call. In a resumed conversation the new proxy is not bound
until the model calls an ai-crew-sync tool, so until then the hooks find the
previous process's closed binding and stay silent. They never act as a
guessed window.
Waking an idle Codex thread (opt-in)
Codex has no background hook that can start a turn, so the watcher above
does not exist there. ai-crew-sync context codex-wake --thread <id> is the
equivalent, as a local process you start yourself, for one thread at a time.
It talks to the Codex app-server daemon on its Unix socket, and when the bus
has something new for that thread's window it starts a turn
(turn/start) carrying counts, never message bodies. It acts as that window
with its own credential, and only for a thread id Codex itself gave the
window's proxy (the binding reads request-meta); anything else is refused.
It never starts a second turn while the thread is busy, does not wake for
what was already waiting, and records each attempt on the window's presence
(attention.wake: codex-adapter, attention.last_wake: requested,
confirmed or failed). Verified on codex-cli 0.163.0 for threads hosted by
the managed daemon; ChatGPT desktop app threads are not reachable from
outside that app. The contract, setup and measured results are in
docs/adapters/codex-wake.md.
Sessions: one agent, several windows
A token identifies an agent (one per coding tool per person), and an
agent usually runs several windows at once. With the plugin or mcp proxy
each conversation already gets its own session — an opaque s-… label, see
One session per conversation
— and there is nothing to configure. A direct HTTP client that cannot start
the binary picks its session by hand with the X-Crew-Session header:
{
"mcpServers": {
"ai-crew-sync": {
"type": "http",
"url": "https://crew.example.com/mcp",
"headers": {
"Authorization": "Bearer ${TEAM_BUS_TOKEN}",
"X-Crew-Session": "${BUS_SESSION:-}"
}
}
}
}
The label is up to 64 bytes of ASCII, with no / (it separates agent from
session in an address), no control characters and no $, { or } — a
literal ${BUS_SESSION} is refused, so use ${BUS_SESSION:-} and an unset
variable sends nothing. It is normalised the way a channel name is (trimmed
and lower-cased, so Market-Data and market-data are one session rather
than two that cannot see each other). The repository name is the obvious
choice.
A session is not identity. It arrives in a header rather than in the token, so it can never make you speak as somebody else; it only separates your own presence, task claims and locks from your other sessions. Omit the header and you get the shared session — exactly how the bus behaved before sessions existed.
The console client takes --session (or BUS_SESSION), and
ai-crew-sync mcp-config --url https://crew.example.com/mcp --token acs_… --session market-data writes the header into a direct-HTTP block (token
included); for a session per conversation, use proxy-config instead.
list_agents then reports one entry per open session under each teammate's
name, so the board says who is in which repository instead of showing one
context that flips every time another session sends a heartbeat:
joaquin
/market-data active Layer-V/market-data@devops/scanning running the suite
/core-manager idle Layer-V/core-manager@issue-151
dani active Layer-V/core-manager@issue-151 settlements v2
online_count counts teammates, not sessions. A session that stops
heartbeating ages out on its own and leaves the others alone.
Finding the right window: project and role
A session label is good at keeping claims and cursors apart and bad at being
typed by a person, especially once labels are opaque ids minted per
conversation. Two optional discovery labels on heartbeat fix that:
project (the logical project, usually the repository) and role (what the
window does there: implementation, design, review, …). Then:
list_sessions {"project": "market-data", "role": "review", "online_only": true}
→ {"sessions": [
{"agent": "joaquin", "session": "s-9c0d1e2f", "address": "joaquin/s-9c0d1e2f",
"project": "market-data", "role": "review", "status": "active", "online": true, …},
{"agent": "joaquin", "session": "s-3a4b5c6d", "address": "joaquin/s-3a4b5c6d", …}],
"count": 2, "limit": 200}
address is what goes in to (or ask_agent's to), and exact says
whether it reaches one window. It is true for a named session and false
for the shared one, whose address is the bare agent name: that reaches every
window of that agent, named ones included, so it is never a private target and
there is no address that reaches the shared session alone. Two reviewers share
a role and keep two exact addresses: discovery returns both and the caller
picks one — nothing is ever routed to "whoever has the role", and a private
instruction is never broadcast to all of them. Labels are what a session said
about itself: not identity (that is the token), not a permission, and freely
shared by several windows. They also drive the default channel: a session that
declared project = "market-data" posts to #market-data when it names no
channel, whatever its session label is — as long as it has one: the shared
session (no label) never gets a default channel. An opaque label that
matches no channel gets no default rather than a surprising one. Omit a label to keep it,
send "" to clear it. whoami reports both, and list_agents shows them
under each session.
Console: ai-crew-sync client sessions --project market-data --role review --online, and client beat --project market-data --role design.
The top-level activity/repo/branch summarise one of a teammate's
sessions, chosen in this order: a live session before a dead one, a
named session before the shared one, then the most recently updated.
Live comes first on purpose. A named session that died three days ago should
not outrank a shared row that is active right now — so the shared row does win
when every named session is offline. Read sessions when you need all of
them; team_digest projects the same way.
A claim and a lock belong to the session that took them, not to the
person. Your core-manager window cannot renew, release or steal a task your
market-data window is holding, and the refusal says so:
market-data#42 is claimed by your own 'market-data' session, and the lease
expires in 240s — continue the work there, or wait for the lease to expire
and claim it here
Without that, one token driving two windows made the lease meaningless between
them: both claimed the same task, both were told they held it, and both did
the work. An expired lease is still up for grabs by anyone, including another
of your own sessions, and it reads that way: the task is open again,
claimed_by is null, lease_expired is true and lapsed_holder names who
let it go. list_tasks {"status": "open"} includes it; renewing it is refused,
claim it again instead.
Direct messages can address a session as well as an agent:
This is what makes a coordinating session useful. A general window can hand
context to the market-data window that has the repository open, and
ask_agent works the same way — including between two of your own sessions:
ask_agent to: "joaquin/market-data" question: "is the suite green?"
Reply to from/from_session, not just to the name, or the answer reaches
whichever of their windows notices first instead of the one that is blocked
waiting for it.
A question from one of your own windows is surfaced like anyone else's: the unit the bus reasons about is the window, not the person.
A timeout is not a failed delivery. Every ask_agent result carries
question_stored: true and a recipient object, which says who the address
reached and what is known about each window it reaches:
routing:exactfor one window,agentfor the bare name, which reaches every window of theirs;windows: for each window,onlineand itsattention(see "Connected is not attending").
On a timeout the suggestion uses it:
- no window connected: nothing is reading the question now;
- a window
attendingorwaiting: an answer may still come, so resume; - idle with a wake mechanism: it may be woken, and the suggestion says so if the last wake failed;
- idle with no way to be woken: the question will likely wait for the human, so do other work instead of waiting in a loop.
Resuming with resume_message_id keeps the same question id and never sends
another message. None of these states promises an answer, and the result
never carries anyone's message bodies beyond the answer itself.
Each session has its own inbox and its own read cursor, so catching up in one
window does not mark another's messages read, and wait_for_updates in one
window does not wake for a question addressed to another. Nothing is hidden
from you, though: read_messages with all_sessions: true returns everything
addressed to you anywhere.
A mistyped session is not an error. A message to joaquin/markt-data is
accepted and waits there unread, because a session that is not open right now
is still a legitimate place to leave work — that is the whole point of handing
something to a window you will open later. The address you used is echoed back
in delivered_to, so a typo is visible in the response. list_agents shows
which sessions are actually live.
The session's channel
A session's default channel is the channel named after its declared
project, else the channel named after its session label (the shared
session has none). With neither channel nor to, post_message lands
there, team_digest summarises it, and wait_for_updates stops waking for
chatter in other repositories' channels. Through mcp proxy, a window's own
channel setting (.acs.toml, --channel or configure_session) overrides
the default for post_message only; team_digest and wait_for_updates
keep focusing on the server's default, so set channel equal to the project
or leave it out. Direct messages, tasks, locks and notes always wake
you — silencing those would hide work rather than noise. all_channels: true
opts back into the whole team on either call, and an explicit channel always
wins.
Resolved by name each time, with no binding to configure and nothing to keep
in sync. A team that does not name channels after repositories simply gets no
default, and says where each message goes exactly as it does today —
whoami reports the resolved channel, or null when there is none.
read_messages deliberately keeps "all" as its default scope. Narrowing it
to one channel would drop your direct messages from the default read, which
is where questions arrive.
Announcements
A channel message only wakes the sessions focused on that channel, which is what makes the focus useful — and what would silence the one message that must not wait. Flag those:
post_message channel: "general" announce: true
body: "migration 0010 lands in 5 min, stop pushing to main"
An announcement reaches every session in the team, whatever each one is
working on, and appears in a focused team_digest too. It is one message with
one id in one channel — not a copy per channel — so replies and reply_to
still work.
Reserve it for what genuinely blocks others: deploys, migrations, breaking changes. A team interrupted for routine progress stops reading announcements, and then the one that mattered is missed as well. The flag is rejected on a direct message, which already arrives unfiltered.
Upgrading
The bus, the CLI and the Claude Code plugin move independently. Nothing coordinates them for you, so upgrade the server first: it is the only piece that owns the schema.
The server. Migrations add to the schema rather than rewrite it, so a
rolling restart is safe and the previous release can usually run against a
newer schema — but only with startup migrations off. A binary that
auto-migrates (the default, BUS_AUTO_MIGRATE=true) refuses to start against
a database that has migrations it does not know ("migration N was previously
applied but is missing in the resolved migrations"). To roll back, set
BUS_AUTO_MIGRATE=false (in the stack's environment, or in
/etc/ai-crew-sync/ai-crew-sync.env for the packaged service) before starting
the older version.
export BUS_VERSION=0.7.10
make deploy # Swarm; or, from the repository root:
docker compose --project-directory . -f Docker/docker-compose.yml pull && make up
The container migrates on startup, and so does the packaged service — both
default to BUS_AUTO_MIGRATE=true — so a .deb or .rpm upgrade is the
package plus a restart:
sudo dpkg -i ai-crew-sync_amd64.deb # or: sudo rpm -U ai-crew-sync.x86_64.rpm
sudo systemctl restart ai-crew-sync
If you turned that off and migrate deliberately, load the environment file
yourself: DATABASE_URL lives in /etc/ai-crew-sync/ai-crew-sync.env
(root:ai-crew-sync, 0640), which systemd loads for the unit and no shell
loads for you.
sudo systemctl stop ai-crew-sync
sudo sh -c 'set -a; . /etc/ai-crew-sync/ai-crew-sync.env; exec ai-crew-sync migrate'
sudo systemctl start ai-crew-sync
Homebrew installs the binary only — no service user, no unit, nothing to
migrate. brew upgrade there updates your client and CLI, which is the next
section.
The console client and the operator CLI are the same binary as the server:
brew upgrade joaquinbejar/tap/ai-crew-sync # or cargo install ai-crew-sync
ai-crew-sync --version
The Claude Code plugin. Since 0.7.0 its MCP server is ai-crew-sync mcp proxy and its authenticated hooks run ai-crew-sync context hook, so the
plugin runs whatever binary is on the PATH: upgrade the binary together with
the plugin. Coming from a 0.6.x plugin (direct HTTP), install the binary
first and add a local profile (ai-crew-sync context profile add …), or keep
BUS_URL/BUS_TOKEN exported. Third-party marketplaces have auto-update
off by default, so refresh it yourself and reload:
/plugin marketplace update ai-crew-sync
/reload-plugins
A teammate who does neither keeps running the plugin version they installed:
Claude Code only offers an update when the plugin's version field changes,
so a release that adds hooks or changes a command reaches nobody until the
marketplace is refreshed. Turn auto-update on for the marketplace in
/plugin → Marketplaces if you would rather not think about it.
Other MCP clients — Codex, Cursor, Zed, a script. Tools live on the
server, so a new tool or a new argument appears the next time the client
reconnects, with nothing to upgrade. Two exceptions live in the client's
config and have to be changed by hand: new headers, such as
X-Crew-Session, and the proxy — a client configured with command: ai-crew-sync runs whichever binary is on the PATH, so that one is upgraded
with the binary, not with the server.
Console client
The same binary talks to the bus from the terminal, as one more agent — useful for humans, scripts and CI:
# With a local profile (see Local profiles) nothing needs exporting; otherwise:
export BUS_URL=https://crew.example.com/mcp
export BUS_TOKEN=acs_...
ai-crew-sync client whoami
ai-crew-sync client tools # what the bus offers this agent
ai-crew-sync client send --channel deploys --body "staging is on 1.4.2"
ai-crew-sync client send --to marta --body "look at PR 421"
ai-crew-sync client read --scope inbox
ai-crew-sync client tasks --prefix api# --status open # pages of 50; prints --cursor for the next
ai-crew-sync client agents
ai-crew-sync client sessions --project market-data --role review # exact addresses
ai-crew-sync client task create refactor-auth --title "Rewrite token refresh"
ai-crew-sync client task create update-clients --title "Update clients" \
--depends-on refactor-auth # pipeline: blocked until the 1st is done
ai-crew-sync client task claim refactor-auth
ai-crew-sync client task done refactor-auth --result "merged in #421"
ai-crew-sync client lock acquire deploy:staging --purpose "shipping 1.4.2"
ai-crew-sync client lock release deploy:staging
ai-crew-sync client send --channel dev --body "parser fix" --file fix.diff
ai-crew-sync client attach fix-parser --file repro.log # attach to a task
ai-crew-sync client download 3 --out fix.diff # fetch attachment by id
ai-crew-sync client ask marta "does staging run pg16?" # DM + wait, one call
ai-crew-sync client wait --timeout-seconds 55 # blocks until something happens
ai-crew-sync client digest --hours 24 # summary for the standup
ai-crew-sync client note set why-no-redis --scope api --value "..." --tags infra
ai-crew-sync client call get_task --args '{"key":"refactor-auth"}' # escape hatch
Also: search, channels, channel-create, tasks (--status,
--mine), task show|next|renew|release, notes, note get|rm|search,
lock list and beat; ai-crew-sync client <command> --help lists the
flags. All subcommands accept --json for raw output (pipeable to jq).
The conversation tools (on teams that have them) have no dedicated
subcommand; reach them with client call, e.g. ai-crew-sync client call read_conversation --args '{"conversation_id":"…"}'. The console client
presents the agent token, so a seat already taken by a registered window is
refused to it.
Asynchronous publication (teams routed off Postgres)
The default is synchronous: a conversation message's body, recipients and
receipts commit in one Postgres transaction, so stored: true means that
transaction committed and there is nothing to reconcile. Nothing below slows
that down or changes it.
A thread uses an outbox instead when its team is routed to another
backend at the moment the thread is created (team capability --backend jetstream), or after conversations migrate --apply moves it there; a thread
moved back to Postgres returns to synchronous mode at the cutover. The outbox
is the shape any external store introduces: acceptance and persistence become
two events, and the second can fail, time out, or succeed without the caller
hearing.
stored the backend confirmed, with a canonical locator
pending_publication accepted, not yet confirmed — and reported as such
failed it will not be confirmed; the slot stays, explicit
A send on that path returns stored: false with publication: "pending_publication", the same word a read of the message uses, and the
receipts carry no stored_at until the backend confirms. That reply means
accepted and recorded, not lost: sending it again would post a duplicate.
Repeating the call with the same request_id is safe and returns the
message's current state, stored once the backend confirmed it or failed
if it never will. Slots are leased (60 s), fenced by a
generation so a worker that comes back after its lease expired writes
nothing, retried with backoff up to eight attempts, and bounded in payload
size. Network-like work happens outside every database transaction: a
publish that takes a minute costs a lease, not a lock.
An uncertain completion — the write may have landed and the confirmation was
lost — is resolved by presenting the same envelope again under the same
publish key, never by guessing and never with an empty probe. Retries present
the same key too, so a duplicate physical write resolves to one canonical
locator rather than two messages. A publication that settled failed is
terminal: reconciliation never takes it back.
Postgres is the default backend, and the only one an untouched installation uses. Changing a team's route, in either direction, is refused while any of its messages is still awaiting publication, because a thread would otherwise keep a gap nobody drains.
JetStream: routing a team's conversation bodies
A JetStream adapter sits behind the backend boundary, and a team can be
routed to it. A default installation never contacts a broker:
teams.default_backend is postgres, ordinary teams stay on the
synchronous path, and NATS does not need to be running. Merging, installing
or upgrading moves nobody's data.
What is in place, proven against a real broker in the test suite:
- Two file-backed, bounded streams per team, named from the team id so a
rename never moves data and a slug never reaches the broker: bodies
(
ACS_T_<team id>: limits retention andDiscardNew, so a full stream refuses new writes instead of quietly dropping history) and inbox references (ACS_I_<team id>: removed when acknowledged, dropped oldest first when full and after seven days, and rebuildable from Postgres). - Provisioning is an operator action with its own credential. The runtime
credential publishes and fetches and cannot create or delete a stream.
Routing does not check that the streams exist: a team routed to JetStream
without them keeps accepting sends, which stay
pending_publication(their bodies are still served from the local copy), and a replica running the publication worker logs the missing stream on every pass. Provision first. - NATS is internal. No subject, stream or consumer name is ever a client-facing argument, and ACS checks every ACL itself.
storedstill means acknowledged. The adapter awaits the PubAck; a publish that has not been acknowledged is not stored, and is not reported as such.- Idempotency on
Nats-Msg-Id, using the outbox's publish key, so a retry inside the deduplication window returns the original sequence. Outside it, reconciliation presents the full envelope again under the same key — never an empty probe, which would take the key the body needs.
The 1 MiB body contract needs two limits raised, not one
The stream's max_message_size is not enough. The server's own
max_payload defaults to 1 MiB, and a 1 MiB body plus its envelope headers
is about 1,048,800 bytes — refused by a couple of hundred bytes. A deployment
that raises only the stream limit rejects exactly the messages the body
contract allows.
nats-server takes max_payload only from its configuration file (there is
no command-line flag; --max_payload makes the pinned nats:2.12-alpine
refuse to start), so the broker is started with a file:
# nats.conf
max_payload: 2MB
jetstream {
store_dir: /data
}
nats-server -c nats.conf
Docker/nats-test.conf is the fixture make test runs; the nats service in
Docker/docker-compose.yml writes its own configuration with the same
max_payload: 2MB before starting (plus store_dir: /data and
max_file_store: 8GB, the budget stream quotas are reserved against).
With it, a body at the contract's ceiling is refused by neither limit. Bodies
past the broker's limit fail fatally rather than retrying for ever, along
with a full stream and a refused authorization; a timeout or a dropped
connection stays retryable.
The integration suite requires a real broker: make test starts NATS 2.12
with JetStream, and a run without TEST_NATS_URL fails visibly instead of
skipping. A broker test that skips itself proves nothing and reads like a
pass.
Routing a team, and what routing does not do
Three independent steps, in this order. None implies the next, the first two
change nothing by themselves, and routing does not verify them. All of them
are operator commands next to Postgres, and the team's conversations must be
on (team capability --conversations on):
# 1. The streams. An operator action with the PROVISIONING credential —
# the server's own credential deliberately cannot create streams.
# Quotas are reserved against the broker's max_file_store at creation,
# used or not (defaults: bodies 2 GiB / 100,000 messages, inbox
# references 256 MiB / 100,000). Size them for the broker you have.
ai-crew-sync team stream --team acme --nats-url nats://broker:4222 \
--nats-credentials ./provision.creds --max-bytes 512MiB --inbox-max-bytes 32MiB
# A re-run keeps an existing stream's limits and says so; changing them is
# explicit, and refused below what the stream already holds:
ai-crew-sync team stream --team acme --nats-url nats://broker:4222 \
--nats-credentials ./provision.creds --max-bytes 1GiB --update-quotas
# 2. The server has to reach the broker, with the RUNTIME credential. In the
# compose stack