Private agents

Keep an agent, MCP server, or skill out of public discovery and share it with only the agents you choose. Privacy here has two independent layers, and you almost always want both: who can find it, and who can call it.

Two layers, on purpose. Hiding something from discovery is not access control — an unlisted endpoint is still reachable by anyone who learns the address. Visibility controls the registry; signed caller identity controls the agent itself. Use visibility to stop it being found, and aroha-sig to stop it being called.

Layer 1 — visibility

Every registered agent, MCP server, and skill has one of three visibility levels:

public

Listed in the Hub and returned by capability search. The default.

unlisted

Never appears in listings or search, but anyone who knows the DID can resolve and call it. Share-by-link.

private

Invisible unless you own it or hold a live grant. Strangers get a 404 — not a 403 — so the endpoint can't be used to prove it exists.

Set it in Studio under Sharing & visibility on your agent's page, or directly against the registry:

curl -X PATCH "$REGISTRY/v1/agents/$DID_HASH/visibility" \
  -H "Authorization: Bearer $AROHA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"visibility":"private"}'

You can also register privately from the start by passing visibility in the registration body. Reviving a deactivated private agent keeps it private — the level is never silently reset.

Layer 2 — access grants

A grant says “this specific party may see this private resource”. Grants carry an optional capability subset, a label, and an expiry, and can be revoked at any time (revocation is a soft delete, so the audit trail survives).

# Share with a partner org so their key can discover it
curl -X POST "$REGISTRY/v1/grants" \
  -H "Authorization: Bearer $AROHA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "resourceDidHash": "'$DID_HASH'",
    "resourceType": "agent",
    "granteeApiKey": "sk-aroha-partner-key",
    "note": "Partner integration",
    "ttlMs": 2592000000
  }'

# …or with a specific agent DID, for call-time allow-lists
#   "granteeDid": "did:aroha:their-agent"

curl "$REGISTRY/v1/grants?resource=$DID_HASH" -H "Authorization: Bearer $AROHA_API_KEY"
curl -X DELETE "$REGISTRY/v1/grants/42" -H "Authorization: Bearer $AROHA_API_KEY"

Two grantee forms exist because the layers authenticate differently. Registry callers identify with an API key, so discovery grants are key-scoped. Agents identify with a DID, so call-time grants are DID-scoped. Set both to cover both.

Layer 3 — who may call it

A bearer token is a shared secret: everyone holding it looks identical, and you cannot revoke one caller without rotating for all. For a private agent you want caller identity. Set auth: "aroha-sig" and list the DIDs allowed to call:

import { serve } from "@aroha-sdk/run";

serve("private-agent", async ({ message }) => `handled: ${message}`, {
  auth: "aroha-sig",
  allowedCallers: ["did:aroha:alice", "did:aroha:ci-pipeline"],
  // resolveCallerKey defaults to the Aroha registry
}).start(8000);

Callers sign each request with their agent key:

import { signRequest } from "@aroha-sdk/run";

const body = JSON.stringify({ message: "hello" });
const headers = await signRequest({
  did: "did:aroha:alice",
  privateKey: alicePrivateKey,
  method: "POST",
  path: "/v1/run",
  body,
});

await fetch("https://private-agent.internal/v1/run", {
  method: "POST",
  headers: { "Content-Type": "application/json", ...headers },
  body,
});

What the signature actually protects

The signature covers METHOD | PATH | TIMESTAMP | NONCE | SHA-256(body), so a captured request cannot be reused elsewhere or altered:

  • Tamperingchanging the body after signing invalidates the signature
  • Cross-endpoint replaya signature for /v1/run won't verify against another path or method
  • Impersonationsigning with your own key while claiming another DID fails verification
  • Replaya nonce guard rejects byte-identical repeats; a forged request never burns a genuine caller's nonce
  • Stale requeststimestamps outside a 5-minute window (configurable) are rejected
  • Unknown callersa DID whose key can't be resolved is rejected — as is any caller off the allow-list, before key resolution even runs
Every ambiguity resolves to deny — missing headers, an unresolvable key, a resolver that throws, a clock too far off. A runnable demo exercising all six attacks ships as examples/private-agent/demo.mjs.

MCP servers and skills

Visibility and grants work identically for MCP servers and skills — use /v1/mcps/:didHash/visibility, /v1/skills/:didHash/visibility, and resourceType of mcp or skill on grants.

One nuance worth planning for: an MCP server is usually called by an agent rather than directly, so the cleanest private-MCP pattern is to expose it through an agent that holds the caller check — put mcp-guard in front of the tools, and aroha-sig in front of the agent.

Choosing a setup

Internal fleet, nothing public

private + aroha-sig with an allowedCallers list

Nobody can find it, and only your agents can call it.

Sharing with one partner

private + a DID grant and a key grant for them

They can discover and call it; nobody else can do either.

Beta agent, not ready to announce

unlisted

Reachable by link for testers, invisible in the Hub.

Public agent, restricted actions

public + mandates or mcp-guard

Anyone can find and call it; authority is what's bounded.