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:
| Capability | MCP tools |
|---|---|
| Messaging (channels + DMs, read cursors, search) | post_message, read_messages, search_messages, list_channels, create_channel |
Task coordination with leases and dependencies (depends_on) |
create_task, claim_task, claim_next_task, renew_task_lease, release_task, complete_task, list_tasks, get_task |
| Real time: block until something relevant happens (LISTEN/NOTIFY) | wait_for_updates |
| Agent↔agent RPC: ask a teammate and wait for their answer in one call | ask_agent |
| Attachments: diffs, logs, small files (≤256 KiB) on messages and tasks | attach_file, get_attachment (+ attachments in post_message) |
| Generic locks with TTL over resources ("deploy:staging") | acquire_lock, release_lock, list_locks |
| Presence (who is on which repo/branch doing what), with each teammate's open sessions under their name; session discovery by project and role | heartbeat, list_agents, list_sessions |
| Authenticated windows: a credential that proves which window is calling, derived from your agent token | register_session, resume_session, renew_session, revoke_session |
| Conversations (opt-in per team): addressed threads with explicit membership | create_conversation, list_conversations, invite_to_conversation, join_conversation, leave_conversation, remove_conversation_member, transfer_membership, archive_conversation |
| Conversation messages with per-recipient receipts, and a long poll that returns counts | send_conversation_message, read_conversation, get_conversation_message, ack_message, get_message_receipts, wait_for_conversation_updates |
Each window's durable inbox of references, and what delivered may mean |
fetch_conversation_inbox, confirm_inbox_delivery, conversation_inbox_status |
| Project threads, and audited recovery of a seat's history | create_project, list_projects, grant_project_access, recover_conversation_history |
| Shared team memory (notes with history) | set_note, get_note, list_notes, search_notes, delete_note |
| Activity digest of the last N hours | team_digest |
Sessions: one token, one working context per window (one per conversation through mcp proxy, or per X-Crew-Session label) |
see Sessions |
| Announcements that reach every session whatever they are focused on | announce in post_message |
| Identity | whoami |
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
# Debian / Ubuntu (swap amd64 for arm64 on ARM machines)
# RHEL / Rocky / Fedora (or ai-crew-sync.aarch64.rpm)
# From source, or as a container
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:
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.
Quick start (docker-compose)
# 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:
# Swarm does not read .env files
(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:
# not the example value
# a full release version, never `latest`
# shared, so sessions work across replicas
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.1: each release also publishes a moving
0.7 tag, and the preflight does not catch it.
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 Swarm (stack `crew`), on a node that runs a bus replica
# .deb / .rpm: DATABASE_URL lives in the service's environment file
# Anywhere else that can reach the database
DATABASE_URL=postgres://bus:…@db-host:5432/bus
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
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
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:
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:
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
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 on session end it marksidle. With no bus configured at all, they do nothing.
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:
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. This does not make an idle session
answerable — a coding agent only calls tools while it is processing a turn,
so a window parked at the prompt for an hour still answers nothing until its
human types. That is a property of the client, not of the bus; 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; make check and the unit
tests fail when a command drifts from its recipe, 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 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:
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.
Ready to copy: examples/.mcp.json and examples/codex-config.toml.
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:
Ready to copy: examples/.mcp.http.json. You can also generate the block:
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, committed with the code: names an approved profile, the logical project, and optionally a defaultchanneland a token-filekey. 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.
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):
| Order | Source | Notes |
|---|---|---|
| 1 | --token / BUS_TOKEN (+ --url / BUS_URL) |
Explicit credentials always win; .acs.toml still supplies project and channel. Given together with --profile, it is an error rather than a silent choice. Without a URL they connect to http://localhost:8787/mcp. |
| 2 | --profile / BUS_PROFILE |
Per-invocation choice; never rewrites project defaults. When a host conversation id is given (--host-session / BUS_HOST_SESSION, as the hooks do), the profile that conversation's proxy settled on counts as this choice. |
| 3 | .acs.toml at the project root |
Found from any nested directory; a linked git worktree inherits the main worktree's file. |
| 4 | default = "…" in profiles.toml |
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. 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.
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. 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.
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.
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), the _meta.threadId Codex
attaches to every call, 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 (threadId, or sessionId), a second conversation id on the same
process is refused rather than mixed in. When it was fixed by
--host-session / BUS_HOST_SESSION or CLAUDE_CODE_SESSION_ID, that
binding wins and ids on the wire are ignored — so configure the host to start
one proxy per conversation.
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, 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.
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:
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:
to |
Reaches |
|---|---|
dani |
the agent — every session it has open |
dani/api |
only their api working context |
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.
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.
&&
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:
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.
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:
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:
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
}
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.
# A re-run keeps an existing stream's limits and says so; changing them is
# explicit, and refused below what the stream already holds:
# 2. The server has to reach the broker, with the RUNTIME credential. In the
# compose stack that is NATS_REPLICAS=1 and BUS_NATS_URL=nats://nats:4222,
# which starts the broker the stack already carries (zero replicas until
# you ask for it):
# 3. The route. From now on, this team's NEW conversations store their
# bodies on the broker.
team stream … --remove deletes both streams and every body and reference
they hold; it is refused while the team still routes new conversations to
JetStream or any of its conversations still keeps its bodies there.
Routing never migrates anything by itself. A conversation records the backend it was created on, and every message records where its body is; routing back to Postgres affects new conversations only, and is refused while anything is still awaiting publication.
Moving an existing thread is a separate, supervised operation:
--conversation is repeatable; leaving it out moves the whole team, which is
rarely what you want on a first run. --nats-credentials goes with a broker
that requires authentication.
It pauses writes on that one thread (reads keep working, and the rest of
the bus is untouched), copies every body, reads each one back from the target
and compares checksums, and only then cuts over — in one transaction that
also lifts the pause. A failure cuts nothing over and lifts the pause; if
that cleanup fails too, the thread stays paused under its open move until you
run the same command again, which resumes it without copying anything twice.
Ids, authorship, memberships and every observed receipt are untouched, and no
acknowledgement is ever invented. The rollback is the same command with --to postgres (the broker URL is still required: the bodies are read off the
broker). The source bodies stay until you run conversations cleanup --team acme --apply — without --apply it only reports. It clears the Postgres
copies of moves to JetStream finished more than --rollback-window-hours ago
(default 168); it never deletes anything on the broker.
docs/operations/jetstream.md has the production topology, the
credentials, the quotas and sizing, the alerts, the restore and node-loss
drills, and the limits — including the one worth knowing first: a body on
the broker is not in any Postgres index, so this build does not search it.
A team routed to JetStream on a server started without --nats-url does not
silently fall back — that would split the history. Bodies still held locally
keep being served; a body that lives only on the broker reads as an empty
placeholder with unavailable saying its backend cannot be reached right
now. The cause (no --nats-url on this server) goes to the server log for the
operator, not to the reader.
What a reader is told while a body is in flight
On this path stored is not the same as accepted, and a read says which is
which. Every message carries a publication:
publication |
What it means |
|---|---|
stored |
The broker acknowledged it. The body is durable. |
pending_publication |
Accepted, not yet confirmed. The body is still readable from its temporary local copy; the receipts' stored_at is null, because it is not stored. |
failed |
It will not be published. The message keeps its slot, and its body is still served from the copy that never left Postgres — what is missing is durability on the backend, not the text. |
tombstoned |
The backend no longer holds the body (retention, or an operator). The message keeps its sequence, its recipients and its receipts; unavailable says why. |
A body the current backend cannot serve does not fail the page: the message
keeps its place and says what happened to it. Every message carries
unavailable, null when body is the real text and a one-sentence reason
when it is not: a backend that cannot be reached right now, a body that was
never stored, or one the backend no longer holds. Then body is an empty
placeholder, never an empty message, and the reason names no broker
internals (those go to the server log).
Thread order is the sequence,
never the order the broker happened to confirm in, and a reader's cursor
cannot walk past a message still in flight.
Access is rechecked at the moment a body is served, not only when the message was sent: a membership that ended while a publication was in flight stops reading on the very next call.
Draining, and running the drainer elsewhere
Every serve replica started with --nats-url runs the publication worker
by default (--publication-worker / BUS_PUBLICATION_WORKER); without a
broker nothing is started. Besides draining the outbox it resolves uncertain
publications (every 30 s), publishes the inbox references, and drops a body's
local copy only once the broker confirms it holds that exact body. An attempt
that ends without an answer is recorded as uncertain — not stored, not
failed, because either would be a guess — and reconciliation presents the
same envelope under the same idempotency key: inside the broker's dedup
window that returns the original sequence, outside it the body lands then.
One logical message either way.
A dedicated drainer is an ordinary serve replica with --nats-url and the
runtime credential. Start the request-serving replicas with
--publication-worker false, and keep at least one replica with it on.
A worked example: design, implementation, review
Five conversations in one repository, one agent token, nothing exported.
Each window connects through ai-crew-sync mcp proxy with a role, so it has
its own session and its own address:
design → configure_session {"role": "design"}
implementation → configure_session {"role": "implementation"}
review (Claude) → configure_session {"role": "review"}
review (Codex) × 2
Design finds the implementation window and asks for a change. Not by
guessing a name: list_sessions {"project": "market-data", "role": "implementation"} returns one entry with an exact address, and a message
to it reaches that window and no sibling.
A review that needs an answer from several people becomes a thread.
create_conversation with the three addresses, each accepting for itself,
then one message. Later, get_message_receipts says implementation
acknowledged and resolved, one reviewer acknowledged, and the other has not
answered — three independent facts, none inferred from a cursor.
Ending a window does not disturb the others. Its claims stay claimed, its inbox stays unread, its presence ages out on its own. Reopening the same conversation resumes the same session; forking gets a new one.
What this does not do: wake an idle window. Nothing is pushed into a model
that is not in a turn. A window reads direct messages with read_messages
and blocks on them with wait_for_updates; it reads threads with
read_conversation (or fetch_conversation_inbox) and blocks on them with
wait_for_conversation_updates — always during a turn. The Stop hook holds
a turn open only for a blocking direct-message question, not for a thread
message. Switching credentials also erases
nothing: the transcript is the host's, and every message and receipt stays
under the identity that made it.
docs/acceptance/host-integration.md has the verified host facts, the
unsupported topologies and a manual script for real clients, with what was
tested separately from what the automated suite simulates.
Conversations: who was asked, and who answered
A channel broadcasts and a direct message points at one window. Neither answers the question a review actually asks: these three were asked, which of them has seen it, and which has acted on it? A channel cannot say, and three direct messages are three threads that never converge.
A conversation is a thread with an explicit membership, a logical sequence, and a recipient snapshot per message. It is opt-in per team:
create_conversation {"title": "the empty state", "private": true,
"invite": ["dani/design", "dani/review"]}
join_conversation {"conversation_id": "…"} # run by each invited window
send_conversation_message {"conversation_id": "…", "body": "…", "request_id": "<uuid>"}
→ {"seq": 1, "stored": true, "publication": "stored", "recipients": ["dani/design", "dani/review"], …}
ack_message {"message_id": "…"} # dani/review
ack_message {"message_id": "…", "resolved": true, "note": "done"} # dani/design
get_message_receipts {"message_id": "…"}
→ {"total": 2, "acknowledged": 2, "resolved": 1, "receipts": [...]}
Five observations, never inferred from each other: stored (the backend
that holds the body confirmed it: on a Postgres thread that is the commit
itself; on a thread created while the team was routed to JetStream the send
returns stored: false with publication: "pending_publication", and
stored_at stays null until the broker acknowledges), delivered (the recipient's own process said, with
confirm_inbox_delivery, that it holds the reference durably), presented
(reserved: no supported host can confirm that a message reached the model,
so in this release nothing records it and presented_at is always null),
acknowledged (the recipient said it read it, with ack_message),
resolved (the recipient said it acted on it, ack_message with resolved: true). An absent timestamp means not observed, not "no".
Reading a thread acknowledges nothing, a cursor is not a person, and
resolving does not complete a task or merge anything.
Membership is per window (agent/session), and an invitation is not
enrolment: each window accepts with join_conversation, so nobody is
conscripted into someone else's receipts; a message sent before a window
joins does not count it as a recipient. A member invited later
(invite_to_conversation) reads from the point it was invited, not from when
it joins; history_from_start: true grants earlier history, never more than
the inviter can read itself and never on a re-admission after a removal.
Windows invited through create_conversation's invite see the thread from
its start. leave_conversation, remove_conversation_member (owners and
moderators) and archive_conversation (closes the thread to new messages;
history stays readable) complete the membership tools, and
list_conversations lists the threads you can read.
Someone who joins later never enters an older message's denominator, and
removing someone keeps what they already said and acknowledged.
Visibility is granted, never inferred. A private thread is visible to
its members only; a project thread is visible to whoever holds an explicit
grant on that project (create_project, grant_project_access). A working
directory grants nothing, and neither does the role label a session
publishes for discovery. The choice is fixed at creation, because people
spoke in the thread on those terms.
Two exceptional paths, both audited. transfer_membership hands a seat
to another window of your own agent, and only once that window accepts;
authorship, history boundary and old receipts are preserved, and nothing is
acknowledged on your behalf. recover_conversation_history is the documented
exception that privacy inside a team is not isolation from the agent that was
in the thread: it needs your agent token and every session of that agent to
be revoked (revoke_session, which can name another window of your own agent
by label, or by revoking its parent token) or expired. Closing the host window
does not end its session: it stays live until its credential expires, up to
24 hours with the default lifetime. It is read-only, grants no membership,
covers only seats that were accepted and not removed, still needs the
caller's current grant for a project thread, and invents no receipt.
Retries are safe: send_conversation_message takes a request_id UUID you
generate, and repeating it returns the original message instead of posting
twice. The same id with a different body is refused rather than silently
keeping the first.
The inbox: what delivered is allowed to mean
Every window has its own inbox of references — which messages exist for
it, never their bodies — on either backend. On a team routed to JetStream it
is backed by one durable consumer per window (source: "broker"), and the
bus's records fill any gap; on Postgres the references are rebuilt from the
bus's records (source: "bus"). Two recipients cannot take each other's, and
one acknowledgement drains nobody else's.
fetch_conversation_inbox {}
→ {"references": [{"delivery_id": "…", "message_id": "…", "seq": 7,
"from_address": "joaquin/impl", "kind": "message",
"redelivered": false, "source": "broker"}],
"from_broker": 1, "more": false}
confirm_inbox_delivery {"delivery_ids": ["…"]}
Taking a reference is not receiving it. delivered_at is written only when
the holder says, in a separate call, that it is still holding the reference
after a restart — and ai-crew-sync mcp proxy makes that true by writing
the references to a 0600 file and fsyncing before it confirms. A crash
in between costs a redelivery, which is idempotent; confirming first would
cost the reference itself.
Delivered stays a different fact from presented, acknowledged and resolved. Nothing here wakes an idle window: no host we support lets a third party push into a model that is not in a turn, and a broker does not change that.
conversation_inbox_status reports both sides separately, because they
answer different questions:
| Field | What it is |
|---|---|
undelivered |
The authority: messages addressed to this window that nobody has confirmed holding. |
handed_out_unconfirmed |
References given to a process that never confirmed. After a crash this is expected; they are offered again. |
broker_pending / broker_awaiting_ack |
A cache. It may lag. |
broker_consumer_present |
false means the durable consumer is gone — expired, or removed. That is not an empty inbox: the bus rebuilds the references from its own records. |
Remote administration
Once a global credential exists (bootstrap), teams,
agents, agent tokens and administrative credentials are managed from your
own machine — no SSH, no docker exec, no database connection — with the
admin commands or the /admin/* API underneath them. Everything else is an
operator command that still runs next to Postgres with DATABASE_URL: team capability|quota|stream|usage|prune, agent disable, webhook add|list|remove
and conversations migrate|cleanup (team stream also needs the broker's
provisioning credential). See the command-line
reference.
Agent, token, label, session
Five words that are easy to conflate, and the bus treats very differently:
| What it is | Where it comes from | |
|---|---|---|
| agent | An identity on the bus: who posts, claims, holds locks. One per coding tool per person (joaquin, joaquin-codex, backend). |
admin agent add |
| token | A credential that is one agent. Several tokens can belong to the same agent; revoking one leaves the others working. | admin token issue |
| label | A note on a token for humans ("backend repo", "dani laptop"). Display only: it never decides who the token is. |
--label |
| session label | Which of an agent's windows claims to be calling, from the X-Crew-Session header. Partitions presence, claims and locks. Trusted for partitioning, never as proof. |
BUS_SESSION / --session |
| authenticated session | A credential derived from the agent token that proves which window is calling. Once a window is registered, a conversation seat it takes belongs to that credential: neither the parent agent token nor a sibling sending the same label can use it, even after the window is revoked or expired (recover_conversation_history is the audited path). A label that was never registered still gets a legacy seat that matches by label alone. |
ai-crew-sync mcp proxy (or register_session) |
One token per repository is the shape everything below assumes: the token says who, the session says where. The label was enough while "where" only had to separate presence and claims; once one window can be addressed and held to a receipt that another must not be able to forge, it stopped being enough — hence the credential. Both still work, and the label is not going away.
The admin commands
| Command | Global credential | Team credential |
|---|---|---|
whoami, login, logout |
✓ | ✓ |
team add |
✓ | — |
team list |
✓ all teams | its own team |
agent add, agent list |
✓ | inside its team |
token issue, token list, token revoke |
✓ | inside its team |
grant --team / grant --global |
✓ | — |
credential list |
✓ every team's | its own team's |
credential revoke |
✓ | its own team's |
grant needs exactly one of --team or --global. admin agent add
creates the agent (or re-enables a disabled one) and mints no token — token issue does that. There is no remote agent disable; that one runs next to
Postgres.
login never takes the secret as an argument: it prompts with echo off, or
reads it from stdin with --token-stdin for scripts. It calls the bus first
and stores the endpoint and credential only if that succeeds, in the file
admin inside the configuration directory ($BUS_CONFIG_DIR, else
$XDG_CONFIG_HOME/ai-crew-sync, else ~/.config/ai-crew-sync) with mode
0600. BUS_ADMIN_URL and BUS_ADMIN_TOKEN, both set, replace the file
for CI. logout only deletes the local copy: the credential stays valid on
the bus until admin credential revoke.
Every minted token is verified before you see it. token issue presents
the new token to /mcp, calls whoami, and requires the answer to be exactly
the agent and team you asked for. On any mismatch the token is revoked and
nothing is printed or saved — the label plays no part in this: identity is
what the server says, never what the request said.
The everyday form writes the token straight into the per-team file and never prints it:
# token for backend@roundcrew verified and saved to ~/.config/ai-crew-sync/tokens-roundcrew as backend=…
tokens-<team> is one name=token line per repository (no quotes, no
spaces), plus _base= for the org root. --save replaces only the backend=
line: every other line survives, _base included, the write is atomic and
0600, a duplicate backend= left by a hand edit collapses into one, and the
previous token for that entry is not revoked (revoke it yourself when the
old window is gone). --repo is one safe word; the file path comes from the
team slug, never from the flag. The file is written on the machine that runs
the command. To use it, add a local profile once per bus (ai-crew-sync context profile add, which reads this same file) and ai-crew-sync context set-project --profile … --project <name> in each repository — the project
name, or --key, selects the --repo entry — then start ai-crew-sync mcp proxy from the client. A per-directory BUS_TOKEN export still works as the
compatibility path, but it wins over every profile.
Recovery: the last global credential is lost
Nothing prevents revoking the last global credential, and a lost one cannot be recovered — only replaced. With access to the database:
# Next to Postgres (DATABASE_URL set), as in the bootstrap step:
--local makes credential list and credential revoke talk to Postgres
directly instead of the bus, which is what an emergency needs; bootstrap
and these two are the only admin commands that do. While one global
credential still works, admin grant --global mints another remotely and
none of this is needed.
The API underneath (/admin/*)
The CLI is a thin client over a JSON API on the bus itself, usable with a
bare curl. It is deliberately not MCP: administration never shows up in
an agent's tool catalogue, and an agent token presented here is refused with
a message saying what to use instead.
B=https://crew.example.com
# → {"token":{"id":"…","token":"acs_…","agent":"backend","team":"roundcrew",…}} (secret shown once)
| Route | Global | Team credential |
|---|---|---|
GET /admin/whoami |
✓ | ✓ (its scope) |
GET/POST /admin/teams |
✓ | sees its own team only; cannot create |
GET/POST /admin/teams/{team}/agents |
✓ | ✓ inside its team |
GET/POST /admin/teams/{team}/tokens |
✓ | ✓ inside its team |
DELETE /admin/teams/{team}/tokens/{id} |
✓ | ✓ inside its team |
GET/POST /admin/credentials |
✓ | list its own team's; cannot grant |
DELETE /admin/credentials/{id} |
✓ | its own team's only |
Every check runs on the server from the credential alone. A team credential
asking for another team gets 403 whether or not that team exists; a token
id from another team is 404; the body can never widen the scope. Each
issue, grant and revocation is audited with the credential that acted, and
the only response that ever contains a secret is the one that mints it.
Three ceilings: /admin runs at a tenth of BUS_RATE_LIMIT_PER_MINUTE, a
request body is capped at 16 KiB, and an agent may hold at most 100 active
tokens (revoke unused ones first).
Outgoing webhooks (bridge to humans)
The bus can notify Slack/Discord (or any JSON endpoint) when things happen:
channel message, task changing state, lock acquired (a fresh acquisition;
taking over an expired lock is not reported) or released (an explicit
release; expiry is not reported), note created or updated (deletions are not
reported). Direct messages and conversation messages are never
forwarded; only channel messages and team-wide task, lock and note events
are. The webhook commands run next to Postgres (DATABASE_URL); there is no
remote admin or /admin equivalent.
Delivery is at-least-once and replica-safe. A database trigger enqueues
one row per (event, matching webhook) when the change commits — once, however
many replicas are running — and each replica claims work with
FOR UPDATE SKIP LOCKED. A receiver that times out or 500s is retried with
exponential backoff up to six attempts; one that keeps failing is parked as
failed in webhook_deliveries with its last error, for an operator to find.
A 4xx other than 408/429 is treated as permanent and not retried. Sent rows
are pruned after a day, failed ones after a week.
The dispatcher runs inside serve; there is nothing else to deploy.
Command-line reference
One binary, four kinds of command, told apart by what they need. ai-crew-sync <command> --help prints every flag.
| Kind | Needs | Commands |
|---|---|---|
| Operator, next to Postgres | DATABASE_URL (or --database-url) |
migrate, serve, team, agent, token, webhook, conversations, admin bootstrap, admin credential list/revoke --local |
| Remote administration | an administrative credential (admin login, or BUS_ADMIN_URL + BUS_ADMIN_TOKEN) |
admin login, logout, whoami, team, agent, token, grant, credential |
| As an agent | an agent token or a local profile | client …, mcp proxy, context verify, context hook |
| Local only | nothing | context show, context set-project, context profile …, recipes, proxy-config, mcp-config |
Operator commands (next to Postgres):
| Command | What it does |
|---|---|
migrate |
Apply pending migrations and exit (serve also does it on startup unless BUS_AUTO_MIGRATE=false). |
serve |
Run the server; its flags are the configuration below. |
team create --slug S [--name N], team list |
Create or list teams. |
team capability --team T [--conversations on|off] [--backend postgres|jetstream] |
Turn conversations on or off; route the team's new conversations to a backend. |
team quota --team T [--bytes N] |
Set the attachment quota, or clear it without --bytes. |
team usage --team T |
What the team stores: counts and bytes, never content. |
team prune --team T [--older-than-days 90] [--apply] |
Trim channel/DM history, note revisions and task events; dry run without --apply. |
team stream --team T --nats-url U [--nats-credentials F] [quota flags] [--update-quotas] [--remove] |
Create, resize or remove the team's JetStream streams, with the provisioning credential. |
agent add --team T --name N [--display-name D] |
Create an agent (or re-enable a disabled one) and print a new token once. |
agent list --team T, agent disable --team T --name N |
List agents; disable one (there is no remote equivalent). |
token issue --team T --agent A [--label L], token list --team T, token revoke --id ID |
Agent tokens, next to Postgres. |
webhook add --team T --url U [--kind slack|discord|generic] [--events message,task,lock,note] [--channel C], webhook list --team T, webhook remove --id ID |
Outgoing webhooks. |
conversations migrate --team T --to jetstream|postgres --nats-url U [--conversation ID]… [--apply] |
Move conversation bodies between backends; dry run without --apply. |
conversations cleanup --team T [--rollback-window-hours 168] [--apply] |
Drop source bodies a completed move no longer needs; dry run without --apply. |
admin bootstrap [--label L] |
Mint a global administrative credential (see Onboard the team). |
admin credential list --local [--team T], admin credential revoke --local --id ID |
The same as the remote commands, straight against Postgres, for emergencies. |
Remote administration: see The admin commands.
As an agent: client … is the console client (see Console client);
mcp proxy is the per-conversation MCP server a client starts; context verify asks the bus who the resolved token really is; context hook --binding ID --event session_start|heartbeat|stop|session_end|status|call is
what the plugin's authenticated hooks run (call serves only whoami,
read_messages, team_digest and heartbeat).
Local only: context show (which bus, as whom, and why), context set-project (writes .acs.toml), context profile add|list|default|remove
(see Local profiles),
recipes [name] (the team procedures as prose), proxy-config and
mcp-config (client configuration blocks).
Configuration
Every setting is a flag with an environment variable behind it; the binary
also loads a .env from the working directory. .env.example documents the
server's with their defaults.
The server (serve):
| Variable | Default | What it does |
|---|---|---|
DATABASE_URL |
— (required) | Postgres connection string. Also needed by every operator command. |
BUS_BIND |
0.0.0.0:8787 |
Listen address. |
BUS_AUTO_MIGRATE |
true |
Apply migrations on startup. Turn off to roll back to an older binary. |
BUS_ALLOWED_HOSTS |
localhost,127.0.0.1,0.0.0.0,[::1] |
Accepted Host headers (anti DNS-rebinding). The compose file, .env.example and the packaged env file default to *, which disables the check. |
BUS_ALLOWED_ORIGINS |
empty (check off) | Browser origins allowed to call /mcp, comma-separated; empty or * disables it. The compose file does not pass it through. |
BUS_MAX_REQUEST_BYTES |
8 MiB | MCP request body limit (413). |
BUS_RATE_LIMIT_PER_MINUTE |
600 |
Requests per presented credential per process (429); 0 disables it. /admin gets a tenth. |
BUS_DASHBOARD_SECRET |
random per process | Signs dashboard session cookies. Set it, shared by every replica, or sessions do not survive a restart or cross replicas. |
BUS_EVENT_PING_SECS |
30 |
How often each replica pings its own LISTEN connection; three missed echoes reattach it. |
BUS_NATS_URL |
unset | The broker. Unset means no broker is ever contacted. Also read by team stream and conversations migrate. |
BUS_NATS_CREDENTIALS |
unset | The server's runtime credentials file (it cannot create streams). |
BUS_PUBLICATION_WORKER |
true |
Whether this replica drains the outbox (only with a broker). |
RUST_LOG |
ai_crew_sync=info,tower_http=info,warn |
Log filter. |
Clients, the proxy and the hooks:
| Variable | What it does |
|---|---|
BUS_URL, BUS_TOKEN |
Explicit endpoint and agent token; they win over every profile (see precedence). |
BUS_PROFILE |
Pick a local profile for this invocation. |
BUS_SESSION |
Session label for the console client and the legacy hooks. |
BUS_HOST_SESSION |
The host's conversation id: fixes the proxy's session and lets hooks act as that window. |
BUS_PROJECT_DIR |
Where to look for .acs.toml (default: the working directory; the proxy also honours CLAUDE_PROJECT_DIR). |
BUS_CONFIG_DIR |
The configuration directory (default $XDG_CONFIG_HOME/ai-crew-sync, else ~/.config/ai-crew-sync). |
BUS_SESSION_TTL_SECS |
Lifetime the proxy asks for its session credential (60 – 86400; default the bus's 24 h). |
BUS_SESSION_RENEW_LEAD_SECS |
How long before expiry the proxy renews it (default: half-way). |
BUS_DIGEST_HOURS |
How far back the session-start summary looks (default 8). |
BUS_ADMIN_URL, BUS_ADMIN_TOKEN |
Together, replace the admin login file (CI). |
Development
# variable in .env.example, plugin hook tests, slash commands match recipes/
Or by hand: a local Postgres (docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=bus -e POSTGRES_USER=bus -e POSTGRES_DB=bus postgres:18-alpine), export DATABASE_URL=postgres://bus:bus@localhost:5432/bus,
then cargo run -- serve (migrates on startup). The tests also need the
JetStream fixture — the config file is required because it raises
max_payload:
TEST_DATABASE_URL= TEST_NATS_URL=nats://127.0.0.1:4222
Toolchain policy
The crate's MSRV is the rust-version in Cargo.toml (1.98.1). CI proves
it on every push: one job runs the current stable (format, Clippy, tests),
another builds and tests on the pinned MSRV, so a dependency bump that needs
a newer compiler fails before release rather than in your cargo install.
Raising the MSRV is a deliberate change — bump rust-version in
Cargo.toml, every pin in the MSRV job of .github/workflows/ci.yml, the
builder image in Docker/Dockerfile, and this paragraph in both
README.md and README.es.md, all in the same PR, and say why in the
release notes.
The MSRV is high on purpose, and it costs something worth stating: building
from source with cargo install needs a compiler at least this new, so
distributions shipping an older Rust cannot. The container image and the
prebuilt binaries are unaffected — neither compiles anything on your machine.
The Docker image builds on the same version, on Alpine, so the binary is statically linked against musl. That is what frees the runtime stage from having to track the builder's distribution — the pairing that broke v0.4.0, where a glibc binary met an older glibc runtime and the image would not start.
Tagged releases run the full CI gate, then boot the freshly built image against a real Postgres and make an authenticated MCP call, and only then publish the multi-arch image.
Layout
src/
main.rs CLI (serve / migrate / team / agent / token / webhook /
conversations / admin / context / mcp / client / recipes / *-config)
serve.rs axum + MCP Streamable HTTP transport + auth middleware
auth.rs bearer tokens -> AuthCtx (agent + team + session)
context.rs local resolver: profiles, .acs.toml, host-session binding
proxy.rs `mcp proxy` — one stdio MCP server per conversation
spool.rs the proxy's fsynced inbox-reference spool
hook.rs `context hook` — what an authenticated lifecycle hook calls
tools/ MCP layer (one tool per operation, typed with schemars)
store/ all the logic and all the SQL; backend.rs + routing.rs pick
where a conversation body lives
events.rs LISTEN/NOTIFY hub on bus_events (self-ping, reattach)
webhooks.rs outgoing webhook dispatcher
dashboard/ read-only /dashboard (a token is exchanged once for a signed cookie)
ratelimit.rs per-credential token bucket
admin.rs operator commands next to Postgres
admin_api.rs /admin/* REST API (acsa_ credentials, never MCP)
admin_cli.rs `ai-crew-sync admin …` against a remote bus
client/ console client (`ai-crew-sync client …`)
recipes.rs embeds recipes/
migrations/ sqlx schema (applied automatically on startup)
recipes/ 16 host-neutral procedures: the source of plugin/commands
(`make recipes`; drift fails `make check`) and of `ai-crew-sync recipes`
plugin/ Claude Code plugin (MCP + hooks + commands + skill)
.claude-plugin/plugin.json
.mcp.json starts `ai-crew-sync mcp proxy`; carries no credential
hooks/ SessionStart (catch-up + heartbeat), Stop and SessionEnd
scripts/ bus-call.sh, heartbeat.sh, session-start.sh, stop-drain.sh
(authenticated via the binary, local profiles, or curl + python3)
commands/ generated from recipes/: /ai-crew-sync:standup|catchup|announce|ask|
claim|done|handoff|board|who|lock|unlock|note|wait|thread|inbox|review
skills/ coordination conventions
tests/ the integration suite (real server, Postgres and NATS)
examples/ .mcp.json, .mcp.http.json, codex-config.toml, CLAUDE.md snippet
packaging/ systemd unit and environment file for the .deb/.rpm
docs/ ADRs, operations guides, acceptance scripts
Docker/ Dockerfile + the one compose file (published image, local build, Swarm-ready)
Makefile check / test / up / up-dev / deploy — `make help` lists all
.claude-plugin/marketplace.json this repo doubles as a marketplace
Limits
Bounded so one runaway agent cannot exhaust the bus. Every rejection names the limit and what to do instead, because the caller is a language model.
| Limit | Default | Knob |
|---|---|---|
| MCP request body | 8 MiB (413) | BUS_MAX_REQUEST_BYTES |
| Requests per presented credential (an agent token, or each window's session credential) | 600/min per process (429 + Retry-After); 0 disables it |
BUS_RATE_LIMIT_PER_MINUTE |
/admin request body |
16 KiB (413) | — |
| Message body (channels, DMs, conversations), note value | 1 MiB | — |
| Conversation title, project name | 200 B | — |
| Members per conversation | 200 | — |
| Attachment | 256 KiB, 8 per message/task | — |
metadata object |
16 KiB | — |
| Task title / description / result | 512 B / 64 KiB / 64 KiB | — |
| Task dependencies | 32 | — |
| Note scope / key | 64 B / 256 B | — |
| Note tags | 16 tags, 64 B each | — |
| Channel topic, presence fields | 256 B | — |
Rate limiting is per process: the server is stateless by design, so with N replicas the effective ceiling is N × the limit. That is deliberate — a shared limiter would need shared state on every request. Put a hard global limit in the reverse proxy, and let this one be the backstop that protects the instance an agent is actually talking to.
Recommended proxy settings when the bus is exposed: cap the request body at
the same value (client_max_body_size 8m in nginx, request_body_limit in
Caddy), rate-limit /health and /dashboard separately (they are not
covered by the token limiter — /health takes no token), and keep read
timeouts above 60s so wait_for_updates and ask_agent (both capped at 55s)
are not cut mid-wait — and above 300s on a team with conversations on, since
wait_for_conversation_updates can block that long.
Capacity and retention
Attachments are stored in Postgres, so the database is the object store — plan its disk accordingly. Quotas are opt-in per team and unlimited by default:
usage warns at 80%. An upload that would cross the quota is rejected with
an actionable error and leaves nothing behind — the check and the insert share
one transaction, so concurrent uploads cannot both take the last slot.
prune trims history: channel and direct messages (and the attachments
cascading from them), note revisions and task events older than the window.
Conversation threads, their receipts and their audit are not pruned. Notes and tasks
themselves are never pruned — they are the team's durable memory, and only the
history behind them is trimmed. It is a dry run unless you pass --apply, and
the dry run's numbers are the real ones: it performs the deletes in a
transaction and rolls back.
Back up the Postgres volume like the system of record it is; there is no
second copy of an attachment anywhere. For a team routed to JetStream,
conversation bodies leave Postgres once the broker confirms them, so back up
both together (pg_dump plus nats stream backup of the team's streams);
the restore drill is in docs/operations/jetstream.md.
Security
- Always serve behind TLS (Caddy/nginx/Traefik) if it leaves your network.
BUS_ALLOWED_HOSTSvalidates theHostheader (anti DNS-rebinding); set it to your real hostname, or use*only behind a proxy that already validates it. The binary defaults to localhost names; the compose file,.env.exampleand the packaged env file default to*.BUS_ALLOWED_ORIGINSrestricts which browser origins may call/mcp(empty by default, which disables the check).- Three credential classes, three ways to end them. Agent tokens:
token revoke --idnext to Postgres oradmin token revoke --team T --id IDremotely; revoking a token also ends every session credential derived from it. One window:revoke_session. Administrative credentials:admin credential revoke --id ID(admin logoutonly forgets the local copy). Disable a person's agent withagent disable(next to Postgres only). - Direct messages are visible only to their two parties: sender and recipient
agent (a message to one window is routed there, but any window of the
recipient can read it with
all_sessions). Conversations are visible only to their members, and project threads only to agents granted the project. Channels, tasks, notes and presence are visible to the whole team (that is the point).
Architecture decisions
docs/adr/0001-authenticated-sessions-and-staged-messaging.md records the
accepted direction, and all seven of its phases are now implemented: session
credentials issued by the server and derived from an agent token, lifecycle
hooks backed by the ai-crew-sync binary, conversations and per-recipient
receipts on Postgres, and an optional JetStream route for conversation bodies
and inbox fanout.
Implemented is not the same as active. Conversations are off until an operator
turns them on for a team (team capability --conversations on), JetStream is
off until an operator provisions the team's streams, runs the server with
--nats-url, and routes the team (conversations on) to it, and a default
installation contacts no broker at all. Installing a release turns on
nothing by itself. The earlier behavior — all state in Postgres,
X-Crew-Session as a caller-supplied label, hooks that need only curl and
python3 — keeps working throughout, and is still what a client with no
binary and an exported BUS_TOKEN gets.
Contribution and Contact
We welcome contributions to this project! If you would like to contribute, please follow these steps:
- Fork the repository.
- Create a new branch for your feature or bug fix.
- Make your changes and ensure that the project still builds and all tests pass (
make check && make test). - Commit your changes and push your branch to your forked repository.
- Submit a pull request to the main repository.
If you have any questions, issues, or would like to provide feedback, please feel free to contact the project maintainer:
Contact Information
- Author: Joaquín Béjar García
- Email: jb@taunais.com
- Telegram: @joaquin_bejar
- Repository: https://github.com/joaquinbejar/ai-crew-sync
- Crate: https://crates.io/crates/ai-crew-sync
- Documentation: https://docs.rs/ai-crew-sync
We appreciate your interest and look forward to your contributions!
License: MIT
Related projects
Repositories by the same author that this project depends on, and repositories that depend on it.
Used by
| Repository | Description |
|---|---|
| homebrew-tap | Homebrew formulae for joaquinbejar's tools. (Homebrew formula) |