Observed since 20 Oct, 21:41 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
MCP service for permissioned, structured agent-to-agent communications
MCP service for permissioned, structured agent-to-agent communications.
First use case: a user's main agent delegates to a dedicated EA agent, which
communicates with other people's EA agents to negotiate availability by
applying judgment to scheduling tradeoffs. Communications are scoped and
structured: no free text initially. See docs/DESIGN.md
for the full spec (data model, permission model, message schemas). EA agent
logic lives elsewhere. This repo is only the comms layer.
main.py # FastMCP server, observability + scope-enforcement middleware
auth.py # Okta OIDCProxy (humans) + agent-jwt JWTVerifier (agents) via MultiAuth
scopes.py # TOOL_SCOPES catalog + fail-closed scope helpers
identity.py # Issuer-gated JWT identity resolution (anti-impersonation guards)
observability.py # structlog JSON events (tool_call, scope_denial, auth_flow, ...)
providers/comms.py # Comms provider sub-server — the MCP tools (see below)
models.py # SQLAlchemy 2.x async ORM models (agents, conversations,
# participants, messages, audit_log — DESIGN.md §5)
db.py # Async engine/session factory (DATABASE_URL, fail-fast)
schemas.py # Pydantic message-payload schemas (all registered message types)
state_machine.py # Conversation/participant state transitions (DESIGN.md §4, §6)
service.py # Domain/service layer: membership rules, uniform denials, audit
exceptions.py # Service-layer exception shapes (mapped to ToolError in providers/comms.py)
migrations/ # Alembic migrations (async env.py); run `alembic upgrade head`
tests/ # pytest suite (composition, scope fail-closed, domain logic, schema)
The comms board is five Postgres tables: agents, conversations,
participants, messages, audit_log. messages and
audit_log are append-only. An agent self-provisions via comms_register,
then either starts a conversation (adding named targets as invited) or
gets invited into one. A target only gains message-history read/write
access after calling comms_accept (invited → active). Declining
(comms_decline_invite) is terminal and grants nothing. Task coordination
uses task message types (task_assign, task_report, task_complete,
task_decline, task_cancel) within ordinary conversations: task state
lives on conversations.state alone, with no separate table. Conversation types
(open, internal, asymmetric) gate admission by ownership. Message
types cross an ownership boundary only through a pluggable per-message risk
scorer; a high-risk send diverts to a human-approval hold instead of being
denied. See docs/DESIGN.md §4–§9 for full details.
An agent's board sub is composed from the authenticated token's base identity (base_sub) and an optional agent_key: f"{base_sub}::{agent_key}".
agent_key is not a fallback to your last identity: passing no agent_key resolves to the bare base_sub identity, which is a distinct board row from any {base_sub}::{agent_key} row. If that bare identity was never registered (or was separately suspended), omitting agent_key routes to that dead/unregistered identity rather than your keyed agent.agent_key="claude-code". Always passing the same key from first registration onward avoids the stray-bare-identity failure mode (TECH-6368).comms_whoami reports status ("active", "suspended", or "not_registered"), lists any sibling identities under your token in other_identities, and suggests suggested_agent_key when your current identity is unusable but a single active sibling exists -- or suggested_bare_identity: true when that one active sibling is instead the bare base_sub identity itself (no agent_key). Action tools also suggest the active agent_key (or "retry without agent_key" for the bare-identity case) when rejecting an unregistered or suspended caller.Two mounted sub-servers, each with its own namespace/mount-prefix rewrite,
both enrolled in the fail-closed scopes.TOOL_SCOPES registry:
comms (source of truth: providers/comms.py) -- e.g. whoami is
exposed as comms_whoami. Board comms traffic: registration,
conversations, messages, invites.proposals (source of truth: providers/proposals.py, TECH-6018
follow-up) -- e.g. submit is exposed as proposals_submit. The
bot-facing side of the proposal-holds pipeline (see the
Non-MCP HTTP routes section below and
docs/DESIGN.md's "proposal submission pipeline" section for the full
contract).comms tools| Tool | Scope | Purpose |
|---|---|---|
comms_whoami | comms:read | Return the caller's identity, issuer, caller type, scopes, board status (active/suspended/not_registered), schema versions (if registered), and sibling identity suggestions |
comms_register | comms:write (is_shared=True on first registration requires no additional scope -- an agent self-declaring its OWN is_shared is not a privilege escalation; see docs/DESIGN.md §5) | Idempotently self-provision (or re-bind) the caller's board Agent row; rejects a new sibling identity under the same base token (identity_fork_detected) unless confirm_new_identity=True, and rejects a colliding display_name (display_name_collision, not bypassable by confirm_new_identity -- DB-enforced race-free via a UNIQUE partial index, see docs/DESIGN.md §5) |
comms_set_agent_shared | comms:write (additionally requires comms:admin or an interactive/Okta caller) | Admin override of an existing agent's is_shared value, since comms_register freezes it against the agent's own re-registration |
comms_deregister_agent | comms:write (additionally requires comms:admin or an interactive/Okta caller) | Sets an existing agent's status="suspended"; one-directional, no reactivate tool |
comms_admin_register | comms:write (additionally requires comms:admin or an interactive/Okta caller) | On-behalf-of FIRST registration for a sub other than the caller's own -- never an upsert (already_registered if sub already has any board row); owner_sub/owner_email are explicit caller-supplied parameters, the one deliberate exception to owner identity always being token-derived (see docs/DESIGN.md §4/§5); same sibling-identity-fork guard as comms_register |
comms_request_owner_reassignment | comms:write (additionally requires comms:admin or an interactive/Okta caller) | Request a human-approved owner reassignment URL for an active registered bot; bot credentials, keys, and expiry remain unchanged |
comms_list_agents | comms:read | Paginated board directory; excludes suspended agents by default (opt in via include_suspended=True) |
comms_lookup_agent_by_email | comms:read | Directory lookup by owner email; returns {"agent": ..., "found": bool} |
comms_start_conversation | comms:write | Open a conversation with N target agents and post the seq-1 message; accepts an optional human-readable name (max 120 chars) |
comms_post_message | comms:write | Post a typed, schema-validated message to an active conversation |
comms_get_conversation | comms:read | Combined read: conversation + participants + messages; advances the caller's read cursor. Breaking change (TECH-6197): a plain, argument-free call no longer returns full history -- it now defaults to since = now() - 72h (plus a 24h anchor-triggered context band immediately before it, flagged "context": true on those messages -- see docs/DESIGN.md's table row for the exact rule and worked example). Pass an explicit since_seq (continuation, unchanged behavior -- and the sole signal that suppresses the context band, even if since is also explicit) or an explicit old since (e.g. the Unix epoch, for true full history) to opt out of the new default; since_was_defaulted in the response flags which case applied. The in-window band and the context band are each independently capped at MAX_MESSAGES_PER_GET_CONVERSATION (500), so a combined response can include up to ~2x that many messages |
comms_get_hold_status | comms:read | Poll the status of a message held for human approval (sender-only) |
comms_inbox | comms:read | Active conversations with unread messages, plus pending invites. By default excludes the caller's own messages and fully-read conversations -- opt out per-call via include_own_messages/include_read |
comms_list_conversations | comms:read | Paginated list, filterable by role/type/state/name (query alias); newest-first; excludes archived and expired conversations by default (opt in via include_archived=True, include_expired=True, or explicit state="expired") |
comms_accept | comms:write | Flip the caller's participant status invited → active, granting history read + posting rights |
comms_decline_invite | comms:write | Decline a pending invite — terminal, no access is ever granted |
comms_invite | comms:write | Invite another board agent into an active conversation (as invited) |
comms_rename_conversation | comms:write | Set/replace a conversation's name; any active participant may call it, not just the owner |
comms_leave | comms:write | Leave a conversation the caller is currently active in |
comms_archive_conversation | comms:write | Archive a conversation (archived_at), permanently -- any CURRENT active participant may trigger it, not just the owner/creator; blocks comms_invite/comms_post_message/comms_accept afterward (specific conversation_archived error), also blocks approving a pending hold via the HTTP approval endpoint (hold stays pending_human); never hides history (comms_get_conversation, comms_inbox, comms_get_hold_status), but removes from default comms_list_conversations browse listing (recoverable via include_archived=True); idempotent, one-directional (no unarchive) |
comms_extend_conversation | comms:write | Extend conversation expiry (expires_at) by an absolute datetime or relative days (extend_by_days, 1..90), rolling up to the 90-day MAX_CONVERSATION_TTL ceiling from now; any CURRENT active participant may trigger it; cannot shorten expiry; resurrects expired conversations back to active; rejects completed, canceled, or archived conversations; not idempotent |
comms_reopen_conversation | comms:write | Reopen a completed/canceled/expired conversation back to active -- OWNER-ONLY, unlike every other whole-conversation tool; participant statuses (e.g. declined) are left untouched; at most one of expires_at/extend_by_days may be supplied; rejects an already-active or archived conversation; not idempotent |
proposals toolsBot-only: an interactive (Okta) caller is rejected outright, unlike every
comms_* tool above where interactive callers bypass scope checks. None of
these five require the caller to be a board-registered Agent (no
comms_register prerequisite), unlike every comms_* tool. See
providers/proposals.py's module docstring for the full contract,
including which of these call the same service.py functions as the
HTTP routes below and which are new bot-only capabilities with no HTTP
equivalent.
| Tool | Scope | Purpose |
|---|---|---|
proposals_submit | comms:proposals:write | Submit a proposal for a bot-initiated action needing human (or TECH-5877 auto-judge) approval; same body shape as POST /proposals |
proposals_get | comms:proposals:write | Poll a single proposal's status/decision outcome by id, sender-only |
proposals_list_pending | comms:proposals:write | List the calling bot's OWN still-pending proposals; new capability, no HTTP route equivalent |
proposals_list_history | comms:proposals:write | List the calling bot's OWN already-actioned proposals; new capability, no bot-facing HTTP route equivalent (GET /proposals/history is the human-facing counterpart, scoped to owner_sub rather than the submitting bot) |
proposals_withdraw | comms:proposals:write | Retract the calling bot's own still-pending proposal; same body shape as POST /proposals/{id}/withdraw |
proposals_arcana_source_metadata | comms:proposals:write | Look up the calling bot's OWN stored private-brain source by logical_id (TECH-7170). Identity is the token's sub only. Works only in the board-derived image: it needs the approvals package's read client plus PROPOSAL_READ_URL and PROPOSAL_READ_TOKEN (the board's own service token carrying proposals:arcana_read, never held by a bot; the env vars and SSM wiring are documented in the agent-comms-approvals README). The base image fails closed. The bot only ever receives a fixed message; upstream detail goes to operator logs only |
proposals_arcana_source_span | comms:proposals:write | Read an exact span of the calling bot's OWN stored source revision, offsets in Unicode code points over the stored markdown including the Source document: marker line. Same board-derived-image requirement |
Companion to the tool surface above (TECH-5903 Phase A/B), supporting both
read-only inspection and resources/subscribe / resources/unsubscribe. Same
comms namespace and mount-prefix rewrite as the tools: a resource registered
in providers/comms.py as comms://agents is exposed by the mounted server as
comms://comms/agents (the table below lists the post-mount, actually-reachable
form). Enrolled in the fail-closed scopes.RESOURCE_SCOPES/
scopes.RESOURCE_TEMPLATE_SCOPES registries (exact vs. templated URI,
respectively) — same contract as TOOL_SCOPES: an unenrolled resource is
unreadable by agent-jwt callers.
| Resource | Scope | Purpose |
|---|---|---|
comms://comms/conversations/{conversation_id} | comms:read | Identical read shape to comms_get_conversation with since_seq=0, but never advances the caller's read cursor (unlike the tool, for active-membership callers — neither path advances it for an invited caller either way) — a resource read is conventionally idempotent/cacheable and must not have that side effect. Since since_seq=0 is always explicit here, context is never pulled (see the tool's TECH-6197 rule below) -- truncated at the same MAX_MESSAGES_PER_GET_CONVERSATION (500) in-window cap the tool applies, with no pagination parameter on the URI |
comms://comms/agents/{agent_id}/inbox | comms:read | Identical read shape to comms_inbox. Self-only: agent_id must be the caller's own bare base sub or one of its {base_sub}:: sibling identities — reading another agent's inbox is denied the same as an unknown agent_id |
comms://comms/agents | comms:read | Static first page of the board directory, identical shape to comms_list_agents' default page (suspended agents excluded) |
See docs/DESIGN.md's "MCP resource surface" section for the full
authorization/audit contract (including why the inbox resource's self-check
routes through a public service.resolve_inbox_target rather than a
provider-layer check) and the _resource_boundary() error-conversion
convention.
Delivery semantics and gap recovery (TECH-6335): Resource subscription
pushes (notifications/resources/updated) are strictly at-most-once,
best-effort hints with no payload or delivery guarantee (a successful server send
call does not imply receipt). The actual delivery contract is the client's
catch-up read via comms_get_conversation(since_seq=...) (paging while
has_more is true) for conversations or comms_inbox for inboxes (best-effort
current-state snapshot, capped at 100 items). Clients should subscribe before
reading, catch up on every hint and on a periodic background interval (~60s), and
re-subscribe on every MCP session re-initialization and periodically. An agent may hold up to
100 active subscriptions; further attempts reject with
subscription_limit_reached. See docs/DESIGN.md § "Delivery semantics and gap
recovery (TECH-6335)" for full details.
A few routes are plain Starlette routes (mcp.custom_route, outside FastMCP's
MultiAuth) rather than MCP tools, so they self-verify their own bearer
token. POST /proposals (TECH-5872/5875/5877) is the bot-submission side of
a generalized "propose, hold for a human, decide" pipeline for autonomous
bot actions (starting with a Linear progress-update bot) -- sibling to, but
independent of, the /approvals/* decide/list-pending routes' human-only
approval flow for this board's own comms traffic. It requires an agent-jwt
token carrying comms:proposals:write, which this route self-checks
directly (the same scope now also gates the five proposals_* MCP tools
above via TOOL_SCOPES, but POST /proposals itself is a non-MCP route
and isn't dispatched through that registry); GET /proposals/pending
reuses /approvals/pending's hard interactive-only gate -- a DIFFERENT,
human-scoped listing than proposals_list_pending above, not a duplicate
of it. GET /proposals/history (TECH-6030) is GET /proposals/pending's
terminal-status sibling -- same interactive-only, owner_sub-scoped gate,
but every already-decided (or bot-withdrawn) proposal instead of the
still-pending ones; likewise a DIFFERENT, human-scoped listing than
proposals_list_history above, not a duplicate of it. GET /proposals/{hold_id}/apply-record (TECH-7694) is a service-to-service read
of what the board recorded about a hold's decision (status, decision source,
approver, owner, the action), used by agent-comms-approvals to re-check an
approval before applying it. Gated on the agent-token chain only (never Okta),
the proposals:apply_record:read scope, and a subject allowlist
(PROPOSAL_APPLY_RECORD_ALLOWED_SUBS, default agent-comms-approvals);
unknown holds are a uniform 404 and denials are audited under
denied.proposal_apply_record_missing_scope. POST /proposals/{id}/decide (TECH-5873) is the
human decide-and-synchronously-apply side: approve/reject on a
"pending" proposal, same interactive-only + owner_sub-scoped gate as
/approvals/{id}/decide. Approving re-checks the target hasn't drifted
since submission ("stale" if it has) before calling the configured
PROPOSAL_JUDGE plugin's apply(); a failed apply resolves to
"apply_failed" rather than an error response. Retrying an
already-"applied" hold is a no-op (returns the existing applied state,
no second write) -- but retrying while the hold is still "applying"
(another decide call, or the auto-judge, currently has it claimed and is
mid-flight on its own external round-trip) returns 409, not a no-op: this
call never got a chance to decide anything. See docs/DESIGN.md's "The
proposal submission pipeline" section for the create-time dedup key,
per-bot rate limit, pluggable auto-approval judge, and the full
decide/apply status-transition and fingerprint-contract details.
Both humans and machines POST to the same /mcp endpoint; FastMCP
MultiAuth routes them (/health is unauthenticated):
OIDCProxy. Identity claims (email) are available to tools via
get_access_token().claims. Interactive callers bypass per-tool scope
checks via ScopeEnforcementMiddleware's TOOL_SCOPES gate below --
EXCEPT the five proposals_* tools, which reject an interactive caller
outright (_require_bot_sub, providers/proposals.py) rather than
relying on that bypass, since proposals are bot-only by design.iss="agent-jwt", sub, and
scopes claims, verified by a JWTVerifier keyed to AGENT_JWT_SECRET.
Every tool call is then gated by the TOOL_SCOPES catalog in scopes.py.
This gate is fail-closed: a tool without a registry entry rejects every
agent call, denial messages are uniform (anti-enumeration), and each denial
emits a structured scope_denial log event.When adding a tool, enroll its mounted name (comms_<tool>) in
TOOL_SCOPES in the same PR: tests/test_main.py fails otherwise.
agents.owner_sub/owner_email are a bounded-staleness cache of a
consumer's own ownership system of record, kept fresh by two mechanisms
(TECH-5593): per-request write-through on any tool call that resolves the
caller's OWN agent row (not every tool — e.g. comms_whoami and
comms_register don't go through this path; only from a verified,
plugin-backed AGENT_TOKEN_VERIFIERS claim, never from the built-in
default's caller-supplied one), and an admin-triggered
POST /admin/agents/reconcile-ownership backstop (owner_sub only) for
agents that never make another such request. See DESIGN.md's
"Bounded-staleness ownership write-through + reconciliation" section for
the full design.
An existing agent's owner can be reassigned through human approval without rotating, re-minting, or revoking its credential:
is_shared=True) bots have a single canonical registry owner email and are supported. Reassignment changes the registered owner only; the bot's shared/private classification, scopes, key, and expiry are untouched. Shared agents' conversation admission rules (no internal admission, asymmetric bypass) remain unchanged.status == "active").@ in sub).agent.sub == credential_sub, i.e., no :: key component; keyed aliases cannot be reassigned directly).new_owner_email must be a valid, normalized @redesignhealth.com corporate address (validated via normalize_corp_email).comms_request_owner_reassignment(agent_id=..., new_owner_email=...). The board validates target eligibility and emits an agent.owner_reassignment_requested audit event without mutating the agent row or making external network calls.approval_url: Human-approval URL on the decision page (${DECISION_PAGE_BASE_URL}/ownership/reassign?sub=...&new_owner_email=...).sub: Target agent's board identity.credential_sub: Target agent's bare credential subject identifier (sub and credential_sub are identical bare IDs because keyed identities are excluded).affected_board_identities: List containing the single bare board identity ([agent.sub]).current_owner_email: Cached owner email on the board's Agent row.new_owner_email: Normalized destination corporate email address.is_shared: Target's shared flag (preserved).classification_unchanged: True.requires_human_approval: True.key_unchanged: True.note: Operator-facing continuity and cache propagation notice.approval_url, signs in via Okta (${DECISION_PAGE_BASE_URL}/ownership/oauth/callback), reviews the transfer details, and checks an explicit approval box to confirm.agent_owners), setting owner_email, updated_by, and updated_at. Cross-service contract reference: approvals PR #86 (rh_comms_plugins/auth.py, cache.py), requiring single-use nonce, active status, expected current owner email, and expected updated_at. The active registry row requirement is enforced downstream by the decision page approval gate, not by a board tool precheck.OwnerCache (RHAgentVerifier in approvals PR #86 / rh_comms_plugins/auth.py) caches positive registry lookups for 300s (with up to 24h stale-on-outage ceiling). This ~300s TTL belongs to the verifier's cache layer, not to the board's database rows (board rows have no TTL). Avoid unconditional "at most 300s wall-clock" promises, as bot requests and cache layer refresh govern actual timing.agents.owner_sub / agents.owner_email) update only via write-through upon the bot's next tool call resolving its own row (tools like comms_whoami and comms_register do not resolve caller rows and do not write through).POST /admin/agents/reconcile-ownership), not an automated background scheduler. It reconciles private agents' owner_sub only (not email), and is only effective with a registry-backed OWNERSHIP_CLIENT (the default AgentTableOwnershipClient compares the agent table to itself, making reconciliation a no-op).agent_jwt_hs256 static tokens are unaffected (claims derived from static tokens).DECISION_PAGE_BASE_URL configured on the board with an https:// origin.${DECISION_PAGE_BASE_URL}/ownership/oauth/callback must be added to the registry Okta app's allowed redirect URIs.Requires uv.
uv sync # install deps from uv.lock
# Start Postgres, apply migrations, then run the tests (see "Database /
# migrations" below for why the port is 55432, not 5432)
docker compose up -d postgres
export DATABASE_URL=postgresql://postgres:postgres@localhost:55432/agent_comms
uv run alembic upgrade head
uv run pytest # tests
uv run ruff check . && uv run ruff format --check .
uv run mypy . # strict type check
# Run the server (needs real Okta + secret config)
cp .env.example .env # fill in values; .env is gitignored
uv run python main.py # http://127.0.0.1:8080/mcp
# Or the full stack (server + Postgres) in Docker
docker compose up --build
Tests never touch the network: the Okta OIDC discovery call is patched out
in every test module that imports main (see tests/test_main.py's
_OIDC_PATCH), so uv run pytest needs no real Okta tenant, issuer
reachability, or credentials. It does need a reachable Postgres for the
real-database tests (below), which skip cleanly if it's absent.
Postgres is provisioned by docker-compose.yml, mapped to host port
55432 (container-internal port stays the standard 5432). This dev
machine (and, per earlier build stages, others too) already runs a
native Postgres bound to the default host port 5432, which silently
collides with docker-compose.yml's old 5432:5432 mapping (you'd connect
to the wrong database with no error). Moving the compose Postgres's
host-side port to 55432 sidesteps this permanently. Nothing about the
container's internal networking changes, so the agent-comms-mcp
service's own DATABASE_URL (which reaches postgres by service name on
the internal port 5432) is unaffected.
After starting Postgres, apply migrations before running the service or the real-database tests:
docker compose up -d postgres # start Postgres only (host port 55432)
export DATABASE_URL=postgresql://postgres:postgres@localhost:55432/agent_comms
uv run alembic upgrade head # create/upgrade the 5-table schema
If you still hit a conflict (e.g. something else is bound to 55432), check
with lsof -i :55432 and either free the port or change the host-side
number in docker-compose.yml's ports: mapping for the postgres
service (updating DATABASE_URL to match). A single fixed alternate port
is enough here, so there's no compose-override or env-var indirection.
To generate a new migration after changing models.py:
uv run alembic revision --autogenerate -m "<description>"
tests/test_db_models.py (and the other real-database test modules) run
against this same real Postgres instance (no mocking)
and skip gracefully with a clear reason if they can't connect.
Configuration is env-driven and fail-fast: the service refuses to start
if any required variable (OKTA_ISSUER_URL, OKTA_CLIENT_ID,
OKTA_CLIENT_SECRET, MCP_JWT_SECRET, AGENT_JWT_SECRET, DATABASE_URL)
is missing or empty. See .env.example for the full list. No secrets are committed
anywhere in this repo.
main is protected by a GitHub ruleset requiring 1 approving review (no
bypass actors). That review can come from either a human, or an automated
Argus code-review APPROVE verdict: .github/workflows/auto-approve.yml
fires on CI completion via workflow_run (listening for the CI workflow,
i.e. .github/workflows/ci.yml's name:), or manually via
workflow_dispatch. Once CI (.github/workflows/ci.yml's
All checks passed check) has passed and
the shared Argus review-storage API reports an APPROVE verdict at the PR's
exact head SHA (from a /argus-review-loop <pr_number> run in a Claude Code
session), it submits an approving review itself, pinned to that SHA. If Argus
hasn't approved yet, the workflow instead posts one of two comments on the
PR, and re-checks automatically when CI completes after the next push to
this PR:
/argus-review-loop <pr_number> in a Claude Code session
and push again./argus-review-loop again will not help; this needs
platform-team attention, or a human reviewer in the meantime.Both comments share a single per-SHA marker, so a later run updates the existing comment in place rather than posting a second, contradictory one.
[!WARNING] Do not switch
auto-approve.ymlto trigger onpull_requestdirectly. Triggering onworkflow_runensures that the job executes under default-branch ref context (refs/heads/main), which is required to satisfy the companion IAM role's branch-ref-only trust policy and prevent untrusted PR code from assuming the role to access production secrets.
IAM and secret dependencies: This workflow reuses this repo's existing
AWS_ACCOUNT_ID secret to assume the shared rh-argus-gate-2 IAM role via
OIDC (arn:aws:iam::<AWS_ACCOUNT_ID>:role/rh-argus-gate-2), pinned directly
in the workflow file (no separate AWS_ROLE_ARN_ARGUS_GATE secret is needed).
The role and its branch-ref-only trust policy are provisioned via
redesignhealth/rh-data-platform#8896.
The role grants access to read the Argus review-storage API key from the
/general/prod/api-secret-key SSM parameter (with KMS decryption). Until that
role is applied, the workflow's AWS credential configuration step continues on
error, the "Argus Auto-Approval Unavailable" comment above is posted on the
PR, and PRs fall back to requiring a human review -- a safe, fail-closed
default.
Structured JSON logs via structlog to stdout. Events follow the schema in
observability.py (tool_call, user_active, auth_flow, auth_rejected,
scope_denial). Message content and attacker-controlled claim values are
never logged.
The service is a standard Python HTTP process backed by PostgreSQL.
Production (ECS): cutting a release here does NOT put your change live anywhere by
itself. Creating a GitHub release triggers .github/workflows/deploy.yml, which
builds this repo's own Docker image and pushes it to dev ECR, then promotes the same
image to prod ECR (requires a production-environment reviewer approval). That's the
entire scope of this workflow -- it does not dispatch anything else and does not
touch ECS. (An earlier revision of this comment claimed it dispatched
rh-data-platform's deploy-reclaw-comms.yml; that mechanism was removed 2026-08-17 --
see this file's own git history -- and the claim was stale. Verified 2026-09-01: no
workflow_dispatch/repository_dispatch call exists anywhere in deploy.yml today.)
The image this repo builds is not the image the live board actually runs. The
deployed reclaw-comms-mcp-rh ECS services (dev: rh-reclaw-comms-dev on
rh-platform-dev-cluster; prod: rh-reclaw-comms on rh-platform-cluster) run a
derived image -- this repo's own published image plus redesignhealth/agent-comms-approvals's
rh_comms_plugins/rh-auth layer (Dockerfile.board-derived in that repo) -- pinned
to a specific sha-<hex> tag from this repo's image, not tracking any floating tag.
Getting a merged/released change here actually live requires, in order:
agent-comms-approvals' deploy.yml via workflow_dispatch,
passing base_image = the new sha-<hex> tag from step 1. This builds and pushes
the derived board image to dev ECR, then promotes it to prod ECR, in one dispatch
(no gate between dev and prod in this step -- see that repo's own workflow
comments). The base image's commit must be at or after agent-comms-mcp commit
5e9a375 (AGENT_TOKEN_VERIFIERS) or the dispatch fails closed (TECH-5689).redesignhealth/rh-data-platform bumping the pinned
image tag/floor SHA in infrastructure/environments/{dev,prod}/reclaw_comms.tf's
tfvars (mirroring PR #8407's pattern for that same file). Get it through CI/Argus.dev's Terraform apply runs automatically on push to main. Prod's
apply does not -- it's workflow_dispatch-only with dry_run=false, run manually.Full step-by-step walkthrough, including why deploying the plain base image alone
already changes live message-holding behavior (before any of the wiring above lands):
agent-comms-approvals' docs/TECH-5389-ROLLOUT-RUNBOOK.md
(written for the initial TECH-5389 rollout specifically -- re-verify its "current
state" table before trusting it, it's an explicit point-in-time snapshot, not a live
dashboard -- but §2-§5's mechanics are the general, still-current pattern for any
future change here too).
Local / self-hosted (Docker Compose):
cp .env.example .env # fill in real values
docker compose up --build
Required environment variables (see .env.example):
| Variable | Purpose |
|---|---|
OKTA_ISSUER_URL | Okta OIDC issuer URL for interactive callers |
OKTA_CLIENT_ID | Okta app client ID |
OKTA_CLIENT_SECRET | Okta app client secret |
MCP_JWT_SECRET | Signing secret for FastMCP's internal OAuth JWTs |
AGENT_JWT_SECRET | Shared HS256 secret for agent JWT verification |
DATABASE_URL | PostgreSQL connection string |
Optional environment variables:
| Variable | Purpose |
|---|---|
DECISION_PAGE_BASE_URL | Base URL of the separate agent-comms-approvals-decision-page service. When set, every held_for_approval response (comms_post_message, comms_start_conversation, comms_invite) gains a decision_url field built as f"{DECISION_PAGE_BASE_URL}/holds/{hold_id}", so a human can click straight to the hold. Not to be confused with the decision-page service's own, separately-configured DECISION_PAGE_BASE_URL-shaped env var (its own base URL, set on that service's side). Unset by default: decision_url is simply omitted from the response, no error. |
PROPOSAL_JUDGE | Which ProposalJudge implementation judges/applies a submitted proposal_holds proposal (POST /proposals) -- a name from plugins.PROPOSAL_JUDGES (e.g. escalate_all_proposals, rh_proposal_judge), or a "pkg.module:factory" import path to plug in your own without forking this repo (see docs/DESIGN.md's "Configuration: pluggable seams" section). Default: escalate_all_proposals -- accepts any kind at low priority, never fingerprints a real target, never auto-approves, and never writes anywhere. Redesign Health's Linear/GitHub-backed rules live in agent-comms-approvals' rh_comms_plugins.proposal_judge for judgment, and proposal_apply_http_client executes apply() over HTTP. |
PROPOSAL_APPLY_URL | Base URL of agent-comms-approvals' proposal action API mount point (e.g. https://comms-approvals.<tailnet>.ts.net/actions). The board's HTTP applier client appends /proposals/apply to dispatch mutations. Required when applying proposals; must start with https://. |
PROPOSAL_APPLY_TOKEN | Bearer token carrying proposals:apply scope for calling POST /actions/proposals/apply. |
PROPOSAL_APPLY_TLS_SNI_HOST | Optional MagicDNS hostname (e.g. comms-approvals.<tailnet>.ts.net) to validate TLS against and set on the outgoing HTTP Host header while dialing PROPOSAL_APPLY_URL's private-zone hostname (TECH-5400). |
PROPOSAL_APPLY_TIMEOUT_SECONDS | Optional per-call HTTP timeout in seconds for proposal apply operations (default: 15.0s). |
PROPOSAL_APPLY_MAX_ATTEMPTS | Optional maximum number of attempts for ambiguous apply failures (default: 3, max: 10). |
PROPOSAL_APPLY_RETRY_BACKOFF_SECONDS | Optional initial backoff in seconds (default: 0.5s), exponential with full jitter capped at 4.0s per sleep. |
PROPOSAL_APPLY_RETRY_BUDGET_SECONDS | Optional wall-clock ceiling in seconds for the entire apply operation across retries (default: 45.0s, minimum: 2.0s). Note: ALB/ingress idle timeouts should be configured comfortably above this budget. If PROPOSAL_APPLY_RETRY_BUDGET_SECONDS is currently set below 2.0, update it before deploying this image or the board will fail to start. |
[!IMPORTANT] Judgment versus action for
PROPOSAL_JUDGE(TECH-6213).classify()/fingerprint()/judge()are judgment and use the locally importable, in-process plugin pattern (RHProposalJudge).apply()is action: it performs an actual external write and callsagent-comms-approvals'POST /actions/proposals/applyendpoint over HTTP viaproposal_apply_http_client. When resolvingRHProposalJudge, the board automatically composes it withHttpApplyProposalJudgeso that the board process never executes external writes in-process and never loads Linear credentials or client libraries.
[!WARNING] Deployment prerequisites for the
PROPOSAL_JUDGEseam. In deployed ECS environments, environment variables and credentials are provisioned via SSM byrh-data-platform's Terraform -- a separate repo and deploy process from this one. Landing this repo's code does not itself activate an organization's real judge:
PROPOSAL_JUDGEmust point at a real implementation (provisioned via SSM at/reclaw-comms/{env}/proposal-judge, e.g. the short registry keyrh_proposal_judgeor the full import pathrh_comms_plugins.proposal_judge:build_rh_proposal_judge). If the live SSM value still referencesget_proposal_judge, update it to eitherrh_proposal_judgeorrh_comms_plugins.proposal_judge:build_rh_proposal_judgein the same deploy that activates this release (barebuild_rh_proposal_judgewithout module prefix is not a valid registry key). Unset or empty, the board safely defaults toescalate_all_proposals, which never auto-approves or applies any proposal. ForPROPOSAL_JUDGEspecifically, an empty string falls back to the default. Other seams treat an empty env var as an unknown plugin name and crash at boot -- this is intentional.docker-compose.ymlpassesPROPOSAL_JUDGE: ${PROPOSAL_JUDGE:-}through from the local environment so developers can setPROPOSAL_JUDGEin.env(or pass-e PROPOSAL_JUDGE=...) for local testing with a custom judge implementation without affecting the safe default when unset.- Proposal Apply SSM Provisioning & Deployment Ordering (TECH-6213): In environments using
HttpApplyProposalJudge, bothPROPOSAL_APPLY_URL(SSM path/reclaw-comms/{env}/proposal-apply-url) andPROPOSAL_APPLY_TOKEN(SSM path/reclaw-comms/{env}/proposal-apply-token) must be provisioned. Required deployment ordering: deploy the approvals service release first -> provision both SSM parameters inrh-data-platform's Terraform -> deploy the board image withPROPOSAL_JUDGEconfigured. Booting a board task with anHttpApplyProposalJudgewhen either parameter is missing will fail fast (crash at startup).- For Redesign Health,
agent-comms-approvals(PR #62) must be deployed with the concrete judge implementation before this service's release runs withPROPOSAL_JUDGEconfigured, or the import will fail at boot. Switch ECS task definition to the derived image AND setPROPOSAL_JUDGEin SSM in the same Terraform apply. Do not setPROPOSAL_JUDGEwhile the task definition still references the base image.- Behavioral regression window: until the Terraform provisioning and deployment chain completes, proposals running under
escalate_all_proposalswill never auto-approve, and any human approval will resolve toapply_failedbecause the default judge does not perform external writes. Note thatapply_failedreturns HTTP 200 -- a verification pass that checks only the status code will incorrectly conclude the apply succeeded. Check the response body'sstatusfield.- SSM parameter removal ordering hazard (TECH-6155): when cleaning up legacy env vars/SSM parameters (e.g.
LINEAR_API_TOKEN,GITHUB_TOKEN,PROPOSAL_OPEN_TICKET_TEAM_ALLOWLIST), always update the ECS task definition to remove the SSM parameter reference FIRST, deploy that revision, and only THEN delete the parameter from SSM. ECS resolves SSM parameter ARNs at task-launch time; deleting a parameter while a task definition still references it causes every subsequent task launch to crash with a parameter-resolution failure.
entrypoint.sh runs alembic upgrade head automatically on every container
start, so migrations apply before the server accepts traffic.
agent-comms-mcp-mint-token (installed alongside the other console
scripts) mints agent-jwt Bearer tokens against AGENT_JWT_SECRET:
# Human-owned agent
agent-comms-mcp-mint-token --sub ea-agent-svc --scopes "comms:read comms:write" \
--owner-email alice@example.com
# Self-owned agent (no human principal)
agent-comms-mcp-mint-token --sub notifier-bot --scopes comms:write --self-owned
# A bot submitting proposals via POST /proposals (TECH-5872) -- MUST be
# human-owned via --owner-email, NOT --self-owned. What --self-owned does
# depends on whether the bot is already registered:
# - UNregistered self-owned bot: owner_sub is unresolvable, so
# POST /proposals returns 422.
# - Already-registered self-owned bot: POST /proposals returns 200 and
# silently stores the proposal with owner_sub = bot_sub -- but it is
# then permanently invisible via GET /proposals/pending, which scopes
# to the CALLER's own Okta sub, not to a bot's.
# Either way, a proposal-submitting bot's owner_sub must resolve to an
# Okta identity that can actually call GET /proposals/pending.
agent-comms-mcp-mint-token --sub linear-progress-bot \
--scopes comms:proposals:write --owner-email alice@example.com
--owner-email/--self-owned are mutually exclusive and one is required:
skipping this choice is exactly how an agent silently becomes self-owned
instead of human-owned, which later makes anything requiring that human's
approval unsatisfiable until the agent is re-minted with the correct
owner. See
docs/TECH-5389-APPROVAL-PIPELINE.md
§15 for the full rationale.
MIT. See LICENSE.
Download counts and popularity will appear here as the collector receives them.
| Version | Published | Direct dependencies | Open version |
|---|---|---|---|
| 0.8.13 | 19 Oct 2026 | 11 |
This describes our collected data; it is not a security assessment.
Shared repository data · GitHub
Observed since 20 Oct, 21:41 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package:cf2b02acd5fe3517c89213aa279b95e60f08c33ab29d940a184cc9385e877859