Self-hosted A2A (Agent-to-Agent) registry with an MCP (Model Context Protocol) server directory. Runs as a single Docker container, persists to SQLite, and ships a vanilla HTML dashboard.
This is the open-source core of Agennect. Use it to publish, discover, and proxy agents that speak Google's A2A protocol, and to maintain a directory of MCP servers your agents can call.
- A2A registry — every agent gets a
.well-known/agent.jsonAgent Card. - MCP directory — list and discover Model Context Protocol servers.
- Semantic search — vector search over agent name, description,
and capabilities using
sqlite-vec. - A2A proxy —
POST /agents/:id/tasksforwards to the agent's REST endpoint, logging every invocation. - Background health checks — periodic GET pings to each agent's
endpoint; status auto-flips between
active,degraded,inactive. - KPI metrics — registry-wide and per-agent counters at
/metrics. - Pluggable embeddings — Anthropic/Voyage, OpenAI, or Ollama.
- Zero external services — one container, one SQLite file, no Redis, no Postgres, no Cloudflare.
cp .env.example .env # then edit ANTHROPIC_API_KEY and ADMIN_TOKEN
docker compose up --build
# in another terminal, seed sample data:
ADMIN_TOKEN=$(grep ADMIN_TOKEN .env | cut -d= -f2) node scripts/seed.js
open http://localhost:3000/dashboardWithout Docker:
npm install
cp .env.example .env
npm start
npm run seedEvery route is mounted at both the canonical /v1 prefix and the
top-level unversioned path. New consumers should use /v1. The
unversioned aliases are kept for backward compat with the dashboard,
seed script, and earlier README examples.
curl http://localhost:3000/v1/metrics
curl http://localhost:3000/metrics # same responseThe full surface is described in docs/openapi.yaml (OpenAPI 3.1) and
served by the registry itself:
| Path | Format |
|---|---|
/openapi.yaml |
Raw YAML (the authored form) |
/openapi.json |
Same spec parsed to JSON |
Paste the URL into Swagger UI, Stoplight, Postman, or any OpenAPI-aware client to generate SDKs or browse the API interactively.
curl http://localhost:3000/openapi.json | jq '.paths | keys'The registry can POST signed JSON to one or more URLs whenever an
auditable event happens. Events match the audit log's action names:
agent.create, agent.update, agent.delete, mcp.create,
mcp.update, mcp.delete, token.create, token.revoke,
webhook.create, webhook.delete, or * for all of the above.
curl -X POST http://localhost:3000/admin/webhooks \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "ops-slack-bridge",
"url": "https://your.service/agennect-hook",
"events": ["agent.create", "agent.delete"]
}'
# → { "id": "...", "secret": "whsec_…", "warning": "Shown exactly once" }The receiver gets a POST with this body:
{
"id": "<uuid>",
"event": "agent.create",
"delivered_at": "2026-06-22T14:00:00.000Z",
"data": {
"actor": "env-bootstrap",
"target_type": "agent",
"target_id": "dataoracle-abcd",
"before": null,
"after": { "name": "DataOracle", "provider": "Agennect", "status": "active" }
}
}And this header:
X-Agennect-Signature: t=1719060000,v1=<hex>
To verify: compute HMAC-SHA256(sha256(secret), t + "." + rawBody) and
compare against v1 in constant time. Node:
import { createHash, createHmac, timingSafeEqual } from 'crypto';
function verify(rawBody, sigHeader, secret) {
const parts = Object.fromEntries(sigHeader.split(',').map(p => p.split('=')));
const key = createHash('sha256').update(secret).digest('hex');
const expected = createHmac('sha256', key).update(`${parts.t}.${rawBody}`).digest('hex');
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(parts.v1, 'hex'));
}Deliveries are fire-and-forget with a 5s timeout. Errors are recorded
on the webhook row (last_status, last_error, failure_count); the
mutation that triggered the event always succeeds even if delivery fails.
agennect-open supports two parallel ways to authenticate, both issuing the same kind of bearer token:
- API tokens — minted via
POST /admin/tokens, used by CI bots, service accounts, and the dashboard's "paste a token" flow. These have ascope(read | write | admin) but no user identity. - User sessions via external SSO — humans sign in through an
external identity provider (Firebase Auth today; pluggable).
The dashboard exchanges the provider ID token for a session token
tied to a row in the
userstable.
Every agent and mcp_server row has an owner_user_id. Mutation
rules are:
| Actor | Mutate own resource | Mutate others' |
|---|---|---|
role = 'user' |
✓ | ✗ (403) |
role = 'admin' |
✓ | ✓ |
| env-bootstrap (no user record) | ✓ | ✓ |
All pre-Sprint-5 rows are owned by a synthetic system user; only
admins can mutate them until reassigned.
- Create a Firebase project, enable Google sign-in (or any provider).
- Copy the project's web API key + project id into
.env:AUTH_PROVIDER=firebase FIREBASE_PROJECT_ID=my-project FIREBASE_API_KEY=AIza... ADMIN_EMAILS=alice@example.com,bob@example.com - Add your registry URL (e.g.
http://localhost:3000) to the Firebase project's "Authorized domains" list (Authentication → Settings). - Restart the server. Open the dashboard → Settings tab → Sign in with Google.
- First login of an email listed in
ADMIN_EMAILSprovisions an admin user; everyone else gets theuserrole.
Implement verifyIdToken(idToken) in src/auth-providers/<name>.js,
register it in src/auth-providers/index.js, then point
AUTH_PROVIDER at it. The rest of the codebase doesn't change.
Admins get a Users tab listing every user with their email, role,
provider, last-login, owned-agent count, and an Actions column for
Promote/Demote and Disable/Enable. Both actions immediately revoke that
user's open session tokens, so the new state takes effect on their next
request. The synthetic system user (legacy data) is not modifiable.
Admins also get a Webhooks tab with the registered subscribers,
their last delivery status (color-coded), failure counter, and per-row
Test / Pause / Resume / Delete actions. The Test button fires a
synthetic webhook.test event so receivers can verify wiring without
having to wait for a real mutation. Paused webhooks skip delivery
entirely until resumed.
| Method | Path | Description |
|---|---|---|
| GET | /auth/config |
Public client config for the active provider |
| POST | /auth/login |
{ id_token } → session token + user |
| GET | /auth/me |
Current token + user |
| POST | /auth/logout |
Revoke the current session token |
Every write endpoint requires a Bearer token. Tokens live in the tokens
table (hashed SHA-256, plaintext shown exactly once on creation) and have
one of three scopes:
| Scope | Allowed |
|---|---|
read |
All public GET endpoints (most don't need auth at all) |
write |
read + POST/PUT/DELETE on /agents and /mcp |
admin |
write + everything under /admin (token management, audit log) |
On first boot, process.env.ADMIN_TOKEN is automatically registered as an
admin-scope token (name env-bootstrap). That preserves the dashboard
workflow: paste the env value, get admin access. Rotating the env var on a
restart updates the bootstrap row's hash to match.
# Mint a write-scope token (must come from an admin)
curl -X POST http://localhost:3000/admin/tokens \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "ci-bot", "scope": "write"}'
# → { "id": "...", "name": "ci-bot", "scope": "write", "token": "agk_…", "warning": "…" }
# List token metadata (never the value)
curl http://localhost:3000/admin/tokens -H "Authorization: Bearer $ADMIN_TOKEN"
# Soft-revoke a token
curl -X DELETE http://localhost:3000/admin/tokens/{id} \
-H "Authorization: Bearer $ADMIN_TOKEN"Every successful mutation under /agents, /mcp, and /admin/tokens
writes an append-only row to audit_log with actor, action, before/after,
and IP. Read it back:
curl "http://localhost:3000/admin/audit?limit=50&action=agent.create" \
-H "Authorization: Bearer $ADMIN_TOKEN"All settings come from environment variables (see .env.example).
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Port the HTTP server listens on. |
HOST |
0.0.0.0 |
Bind address. |
REGISTRY_NAME |
My Agent Registry |
Display name shown in /metrics and the dashboard. |
REGISTRY_URL |
http://localhost:3000 |
Public URL used in generated Agent Cards. |
DB_PATH |
./data/registry.db |
SQLite path. Mount this volume in Docker. |
LLM_PROVIDER |
anthropic |
Embedding provider: anthropic (Voyage), openai, or ollama. |
EMBEDDING_MODEL |
voyage-3 |
Provider-specific embedding model. |
EMBEDDING_DIMS |
1024 |
Must match the model: voyage-3 → 1024, text-embedding-3-small → 1536, nomic-embed-text → 768. |
ANTHROPIC_API_KEY |
— | Required when LLM_PROVIDER=anthropic. Used for the Voyage embeddings endpoint. |
OPENAI_API_KEY |
— | Required when LLM_PROVIDER=openai. |
OLLAMA_URL |
http://localhost:11434 |
Required when LLM_PROVIDER=ollama. |
ADMIN_TOKEN |
change-me-before-deploy |
Auto-registered as the bootstrap admin-scope token (name env-bootstrap). Change it. |
CORS_ORIGINS |
* |
Comma-separated allowlist of origins, or * for any. Replace * before exposing to the internet. |
HEALTH_CHECK_INTERVAL |
300000 |
Health check loop interval in ms. |
| Method | Path | Description |
|---|---|---|
| GET | /health |
Liveness probe. |
| GET | /metrics |
Global registry KPIs. |
| GET | /metrics/agents/:id |
Per-agent KPIs. |
| GET | /agents |
List agents. Query: status, protocol, hosting, page, limit. |
| GET | /agents/search?q= |
Semantic search by natural language. |
| GET | /agents/:id |
Agent detail with capabilities. |
| GET | /agents/:id/.well-known/agent.json |
A2A Agent Card (schema v0.2). |
| GET | /agents/:id/health |
Health checks for the last 24h. |
| POST | /agents/:id/tasks |
A2A task entry point. Logged as invocation. |
| GET | /mcp |
List MCP servers. Query: category, transport. |
| GET | /mcp/:id |
MCP server detail with tools. |
| Method | Path | Description |
|---|---|---|
| POST | /agents |
Register an agent. Generates embedding + card. |
| PUT | /agents/:id |
Update agent. Re-embeds if name/description/capabilities change. |
| DELETE | /agents/:id |
Soft-delete (status=inactive). |
| POST | /mcp |
Register an MCP server. |
| PUT | /mcp/:id |
Update an MCP server. |
| DELETE | /mcp/:id |
Soft-delete an MCP server. |
| Method | Path | Description |
|---|---|---|
| POST | /admin/tokens |
Mint a new token (returns plaintext once). |
| GET | /admin/tokens |
List tokens (metadata only — no value). |
| DELETE | /admin/tokens/:id |
Soft-revoke a token. |
| GET | /admin/audit |
Read audit log (filters: action, target_type, target_id, limit). |
| GET | /admin/users |
List users with owned-resource counts. |
| PUT | /admin/users/:id |
Change role (user/admin) or disable/enable. Auto-revokes the user's sessions. |
| POST | /admin/webhooks |
Register a webhook (returns plaintext secret once). |
| GET | /admin/webhooks |
List webhooks with delivery stats. |
| PUT | /admin/webhooks/:id |
Pause or resume delivery ({paused: true/false}). |
| POST | /admin/webhooks/:id/test |
Fire a synthetic webhook.test event to this subscriber. |
| DELETE | /admin/webhooks/:id |
Delete a webhook. |
| Method | Path | Description |
|---|---|---|
| GET | /openapi.yaml |
Authored OpenAPI 3.1 spec (YAML). |
| GET | /openapi.json |
Same spec, served as JSON. |
| GET | /llms.txt |
Concise, LLM-readable API summary. |
The server logs JSON lines to stdout (one parseable object per line) and
echoes a unique X-Request-Id header on every response so you can trace
a single request through the logs. Override via env:
LOG_LEVEL=debug|info|warn|error(defaultinfo)LOG_PRETTY=1switches to a human-readable single-line format (default in dev / whenNODE_ENV != production)
Clients can pass their own X-Request-Id header and the server will
honor it instead of minting one.
Set LLM_PROVIDER=mock to use a deterministic bag-of-words embedder
that needs no API key. Same text always produces the same vector; texts
sharing tokens cluster under cosine distance. Used by .github/workflows/test.yml
so the semantic-search integration tests run hermetically.
Toggle in the topbar (◐ for dark / ☾ for light); choice persists in localStorage. Default is dark.
See SECURITY.md. Contributions: CONTRIBUTING.md.
The caller may pass X-Agent-Auth: <value> to forward an API key or OAuth
token to the upstream agent endpoint. The registry never persists this value.
See Invocation Modes below for the two ways callers can drive an agent.
agennect-open supports two modes for tracking agent invocations.
Your orchestrator (or calling agent) invokes the target agent directly and reports metrics to the registry afterwards. Zero added latency, no single point of failure, no registry restart pain.
import { AgennectReporter } from './src/sdk/reporter.js';
const reporter = new AgennectReporter('http://localhost:3000', {
callerId: 'my-orchestrator'
});
const { invoke } = reporter.wrap('dataoracle-x7k2');
try {
const { result, latency_ms } = await invoke(
'https://myagent.com/tasks',
{ message: { parts: [{ type: 'text', text: 'Analyze Q1 data' }] } }
);
console.log(`Done in ${latency_ms}ms`, result);
} catch (e) {
console.error('Invocation failed:', e.message);
}Or report metrics for an invocation you handled yourself:
await reporter.report('dataoracle-x7k2', {
latency_ms: 340,
status: 'success',
request_size: 512,
response_size: 1024
});from reporter import AgennectReporter
reporter = AgennectReporter(
"http://localhost:3000", caller_id="my-orchestrator"
)
result = reporter.invoke(
agent_id="dataoracle-x7k2",
endpoint_url="https://myagent.com/tasks",
payload={"message": {"parts": [
{"type": "text", "text": "Analyze Q1 data"}
]}}
)Both SDKs fire-and-forget the report — the caller never blocks on registry availability.
The registry forwards the A2A task to the agent's endpoint, measures everything, logs it, and returns the A2A response. Useful when the caller can't or won't import the SDK, or when you want central auth fan-out.
Enable per-agent via the admin API:
curl -X PUT http://localhost:3000/agents/{id} \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"proxy_enabled": true}'Then invoke through the registry:
curl -X POST http://localhost:3000/agents/{id}/tasks \
-H "Content-Type: application/json" \
-H "X-Agent-Auth: your-agent-api-key" \
-d '{"message":{"role":"user","parts":[{"type":"text","text":"Analyze Q1"}]}}'The response embeds an _agennect block with the invocation id and
latency. If proxy_enabled is false, POST /agents/:id/tasks returns
422 with a hint pointing to /agents/:id/report.
Agent Cards conform to the Google A2A spec (schema_version: 0.2). Each card
exposes the agent's id, capabilities, authentication scheme, supported
input/output modes, and the task URL on this registry:
- Spec: https://github.com/google/A2A
- Card:
GET /agents/:id/.well-known/agent.json
The registry acts as an A2A endpoint for every agent it knows about. Cards
point at this registry's URL — callers don't need to know the underlying
endpoint or auth requirements. Auth is forwarded per-request via X-Agent-Auth.
The MCP directory is a catalog of Model Context Protocol servers your agents can connect to. The registry does not host MCP servers — it lists them, with their transport (stdio/http/sse), config URL, tool schema, and author.
- MCP spec: https://modelcontextprotocol.io
- Browse:
GET /mcp - Detail:
GET /mcp/:idreturns the full tools array.
The registry ships with one built-in agent: Agennect Connect
(id: agennect-connect). It's a normal entry in /agents — searchable,
has an Agent Card at /agents/agennect-connect/.well-known/agent.json,
shows up in the dashboard — but its is_builtin flag tells the invoke
router to dispatch POST /agents/agennect-connect/tasks to an in-process
handler instead of fetching an endpoint_url. Skips the health-check loop
for the same reason.
Walks a caller through registering a new agent via A2A conversation,
validates each field, and creates the agent (owned by whoever's session
is making the request) using the same SQL the POST /agents route uses.
- LLM extractor (when configured) — calls the chat provider chosen
by
LLM_PROVIDERviasrc/chat.js, using structured tool-use so the output is a strict JSON shape. The model pulls fields from free-form input ("I want to register an agent called PipelineBot that does ELT and dbt") and decides the next prompt. Supported providers:anthropic(Claude via tool use),openai(function calling),ollama(format: json). Configure the model viaCHAT_MODELenv (defaults:claude-haiku-4-5-20251001,gpt-4o-mini,llama3.1). - Rule-based state machine (always available) — deterministic
walk:
name → description → provider → endpoint → capabilities → review → submit. Used as a fallback when the LLM path returnsnull(no API key,LLM_PROVIDER=mock, or a network failure).
SESS=demo-$(date +%s)
curl -X POST http://localhost:3000/agents/agennect-connect/tasks \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -nc --arg s "$SESS" '{
id: $s,
message: {role:"user", parts:[{type:"text", text:""}]},
context: { session_id: $s }
}')"
# → bot: "Want me to help you register an agent or an MCP server?"
# Each subsequent turn re-uses the same session_id; the bot remembers
# what you've answered so far.Click the floating 💬 Connect button (bottom right) to open a chat
panel. It stores its session_id in sessionStorage; the ↻ button
starts a new session.
| Variable | Default | Notes |
|---|---|---|
LLM_PROVIDER |
anthropic |
Same env as embeddings: anthropic/openai/ollama/mock. |
CHAT_MODEL |
provider default (above) | Override the chat model. |
ANTHROPIC_API_KEY |
— | Required for anthropic chat (real Claude API key). |
OPENAI_API_KEY |
— | Required for openai chat. |
OLLAMA_URL |
http://localhost:11434 |
Required for ollama. |
With LLM_PROVIDER=mock, the Connect Agent runs the state machine end
to end — useful for offline dev and CI.
If you need single-org self-hosting on commodity infra, use this repo. If you need multi-tenant, hosted, or evaluation features, use the cloud.
- File issues and PRs at https://github.com/agennect/agennect-open.
- To run the integration tests:
npm start & # in one terminal npm test # in another
- To submit a new agent for the public catalog at agennect.com, open a PR with
your agent's metadata in
seed.js-format, or use the Agennect Connect onboarding agent.
MIT — see LICENSE.

