Cross-Instance Collaboration
The technical reference for the seams between Cinatra instances. The user-facing companion explains the workflow and the experience. This page documents the protocol, the routing model, the auth surface, and the source files you read if you are integrating Cinatra with another Cinatra (or another A2A-compliant platform) at the protocol level.
The two seams
Section titled “The two seams”Two protocols carry cross-instance traffic:
- Registry traffic — Verdaccio (npm-compatible). One instance publishes a package; another reads the package listing and pulls the tarball.
- A2A traffic — JSON-RPC 2.0 over HTTPS. One instance’s agent makes a tool call into another instance’s agent; the called agent runs locally on the host, streams AG-UI events back, and (optionally) pauses for HITL.
Both seams reuse Cinatra’s existing primitives — there is no separate “federation” subsystem. The seams are governed by DeploymentRegistryConfig on the registry side and by the per-agent A2A routes plus the OAuth-provider plugin on the A2A side.
Registry routing
Section titled “Registry routing”The runtime answers two questions for any cross-instance package operation: which registry does this destination point at and how do I authenticate. The answer is in the canonical DeploymentRegistryConfig:
type DeploymentRegistryConfig = { publicRegistryUrl: string; publicReadToken: string; publicPublishToken: string | null; privateRegistryUrl: string | null; privateReadToken: string | null; privatePublishToken: string | null; privateDestinationConfigured: boolean; privateDestinationId: string | null; routingMode: "scope-based" | "shared-acl";};Loaded by loadDeploymentRegistryConfig() in src/lib/deployment-registry-config.ts. Caller MUST be auth-gated before invoking — the loader does not call requireAuthSession() itself.
Topology adapters
Section titled “Topology adapters”routingMode selects the topology adapter inside packages/extensions/src/destination-resolver.ts:
scope-based— emits--@<scope>:registry=<url>flags so each scope routes to its own registry vhost. Used when the private destination uses a distinct npm scope from the public registry.shared-acl— emits plain--registry=<url>flags so a single registry vhost serves multiple instances with ACL-based isolation. Used when the public and private destinations share infrastructure.
routingMode is required. An unset value raises DeploymentRegistryConfigNotAvailableError("deployment config malformed — routingMode missing").
Credential storage
Section titled “Credential storage”No registry secrets live in origin. The per-destination credentials sit in the encrypted extension_destinations table, keyed by destinationId, and decrypted with per-field AAD: destination.<id>.publish-token for publish tokens, destination.<id>.read-token for read tokens. The destination resolver pulls credentials by id; callers never touch ciphertexts.
Origin record
Section titled “Origin record”Every install persists an origin JSONB on the primary row (agent_templates.origin, skill_packages.origin):
type ExtensionOrigin = { packageName: string; version: string; destinationId: string | null; // null for public-registry installs scope: string; // "@<scope>" — basis of the visibility filter visibility: "public" | "private"; registryUrl: string; importedFrom?: { source: "github" | "zip" | "chat"; url?: string; license?: string; licenseAcknowledged?: boolean; updatePolicy: "manual" | "auto"; lastSyncedAt?: string; };};The instance’s vendor scope (read from readInstanceIdentity()) is the basis of every server-side visibility filter: public rows are universally visible; private rows are visible only when origin.scope matches the reading instance’s scope. This is enforced at every server read path that touches the extension tables (packages/agents/src/store.ts:readActiveExtensionTemplates, the marketplace screen at src/app/administration/marketplace/page.tsx, the MCP extensions_search handler). A regression test (packages/extensions/src/__tests__/visibility-filter.test.ts) scans for direct callers of the lower-level Verdaccio config helpers that would bypass the filter.
A2A — calling another instance’s agent
Section titled “A2A — calling another instance’s agent”The external A2A surface is /api/a2a (multiplexed JSON-RPC). Discovery happens through /.well-known/agent.json. The per-agent route at /api/a2a/agents/<vendor>/<slug> is internal WayFlow plumbing — it is the local-host proxy that the in-process WayFlow container uses to reach the application; it is gated by CINATRA_BRIDGE_TOKEN and is not the route external instances call.
AgentCard discovery
Section titled “AgentCard discovery”GET /.well-known/agent.json returns a single AgentCard describing the host instance and every published agent installed on it. The route is implemented at src/app/.well-known/agent.json/route.ts, fetches all published templates via readPublishedAgentTemplates(), and delegates to packages/a2a/src/agent-card.ts:buildAgentCard(). The shape:
- Top-level fields:
name: "Cinatra",description,url: "<baseUrl>/api/a2a",version,capabilities,authentication(withtokenEndpointpointing at the host’s OAuth2 token endpoint). skills[]— one entry per published agent. Each entry hasid,name,description,tags,inputModes,outputModes,toolName,packageName,operativeVersion,supportedVersions,hitlScreens(array of{ id, schema }),agentDependencies(a map of@<vendor>/<slug>→ semver range — surfaced flat on each skill so a caller can resolve sub-agent dependencies before invoking an orchestrator), andtype(leaforflow).
Discovery is unauthenticated. The card is a public manifest; only the actual task-send call requires auth.
Outbound calls
Section titled “Outbound calls”packages/a2a/src/external-client.ts:createExternalA2AClient() is the outbound surface. It:
- Exchanges client credentials for a bearer JWT through the remote instance’s OAuth provider plugin (
tokenEndpointadvertised in the remoteAgentCard). - Sends the JSON-RPC
message/sendrequest with the bearer inAuthorization. - Returns
streamTask()for streaming calls, which yields the remote SSE response.
Bridging the remote SSE stream into the caller’s local Redis event log is the caller’s responsibility — packages/agents/src/a2a-actions.ts does this via startExternalSseProxyFromStream() in packages/a2a/src/external-sse-proxy.ts, so the caller’s UI subscribes to the local stream rather than the remote.
The caller’s run sees the remote agent as if it were a local sub-agent. HITL gates on the remote agent surface as INTERRUPT events the caller can choose to resume against (message/sendStreaming with the resume payload) or to escalate to a local human.
Inbound calls
Section titled “Inbound calls”When another instance calls your POST /api/a2a:
- The bearer JWT is verified by Better Auth’s OAuth-provider plugin against the canonical origin (tunnel-safe) via
verifyA2AAccessTokeninsrc/lib/a2a-auth.ts. - The actor is resolved through
resolveA2AActorContext(insrc/app/api/a2a/actor-context-resolver.ts) and threaded through every server action, MCP primitive, and background job. The actor type is"a2a", distinct from"ui"/"route"/"worker". - The agent run executes on this instance’s infrastructure, using this instance’s WayFlow container, this instance’s connectors and credentials, this instance’s LLM-orchestration layer. The caller does not get those resources — they only get the agent’s output.
- The run is durable in the local
agent_runstable; the stream is replayable from the Redis Streams log viaLast-Event-ID.
Authorization
Section titled “Authorization”Per-run authorization on inbound A2A calls uses the same enforceRunAccess(run, actor) helper the UI uses. The actor envelope on inbound A2A traffic identifies the calling instance via the OAuth client; the local instance’s permissions backend controls whether that client is allowed to list, read, or execute the agent. Inbound calls are denied with the same logic that denies an unauthorized UI user.
Flag matrix recap
Section titled “Flag matrix recap”| Route | Default | Gating |
|---|---|---|
GET /.well-known/agent.json (AgentCard discovery) | always on | — (public, unauthenticated) |
POST /api/a2a (external multiplexed JSON-RPC) | 404 | CINATRA_A2A_HTTP_ENABLED=true + Bearer JWT |
POST /api/a2a/resume (external AG-UI resume) | 404 | CINATRA_AGUI_EXTERNAL_ENABLED=true |
AG-UI multiplex inside /api/a2a SSE response | off | CINATRA_AGUI_EXTERNAL_ENABLED=true |
GET|POST /api/a2a/agents/<vendor>/<slug> (internal WayFlow proxy) | always reachable | X-Cinatra-Bridge-Token matching CINATRA_BRIDGE_TOKEN (403 if unset or wrong) |
The asymmetry is intentional. /api/a2a is the external A2A front door and is opt-in; production deployments enable it explicitly. The per-agent /api/a2a/agents/<vendor>/<slug> route is the local-host WayFlow proxy — external A2A traffic never reaches it; only the WayFlow sidecar with the bridge token does.
Auth between instances
Section titled “Auth between instances”Two credentials are involved:
- Bearer JWT — issued by the remote instance’s Better Auth OAuth-provider plugin in exchange for client credentials. The JWT carries the calling client’s identity; the remote instance’s authorization layer maps the client to an actor and runs every gate against the actor.
- Bridge token (in-instance) —
CINATRA_BRIDGE_TOKENauthenticates WayFlow container callbacks into the host app at/api/llm-bridgeand the per-agent A2A routes. It is not a cross-instance credential; it is the secret one instance’s WayFlow container uses to call its own host app.
There is no separate cross-instance shared secret. The OAuth-provider plugin is the canonical surface for both MCP and A2A access; the same JWT works for both.
For the full auth model see Security.
How publishing actually flows
Section titled “How publishing actually flows”End-to-end publish path for an agent:
- Author the OAS Flow +
package.json(chat or file-driven path). - Compile (
agent_source_compile) — derives runtime fields, syncsagent_templates. - Review gate (
agent_source_review/runDeterministicReview) — must pass. - Publish (
agent_source_publish) — bumps version, packs tarball. - The destination resolver in
packages/extensions/src/destination-resolver.tsreads the chosen destination (private|public) fromDeploymentRegistryConfig, decrypts the publish token fromextension_destinationsby destination id, builds thenpm publishcommand via the topology adapter for the resolvedroutingMode, and runs it. agent_templates.originis updated to reflect the published(packageName, version, scope, destinationId, registryUrl, visibility).- An
audit_eventsrow is written.
After publish, other connected instances see the package in their marketplace listing (subject to the visibility filter); installs flow through the normal install path described in Extensions.
Promotion path (private → public)
Section titled “Promotion path (private → public)”promoteExtensionToPublicAction(extensionId) in packages/extensions/src/actions.ts is the one-way audited path:
requireAdminSession().- Refuses with an explicit error if
origin.visibility === "public"already. - Republishes the package at its current version to the public destination using the same topology routing as a fresh publish.
- Updates
origin.visibilityto"public". - Fire-and-forget writes a
promoterow toaudit_events(resourceType: "extension_registry").
The republish step requires a live deployment-registry resolver with a non-null publicPublishToken. The baseline configuration ships a fixture (DEPLOYMENT_REGISTRY_CONFIG_FIXTURE) whose public publish token is null, so resolvePublishDestination("public") throws in fixture-backed environments. Operators wire a real resolver under Administration → Environment → Registries before public publish/promotion will succeed.
Public-to-private demotion is intentionally not implemented; the button is rendered disabled-but-visible with a locked tooltip. The disabled-visible pattern is preferred over hidden so the limit is discoverable in the UI.
Source-of-truth files
Section titled “Source-of-truth files”When you need to verify a specific claim on this page:
- Destination resolution:
packages/extensions/src/destination-resolver.ts ExtensionOriginshape:packages/agents/src/schema.tsDeploymentRegistryConfigshape:src/lib/deployment-registry-config.ts- Visibility filter regression:
packages/extensions/src/__tests__/visibility-filter.test.ts - Marketplace screen + visibility filter wiring:
src/app/administration/marketplace/page.tsx - AgentCard builder:
packages/a2a/src/agent-card.ts - Outbound A2A client:
packages/a2a/src/external-client.ts - External SSE bridge:
packages/a2a/src/external-sse-proxy.ts - A2A actor context resolver:
src/app/api/a2a/actor-context-resolver.ts - Per-agent A2A route:
src/app/api/a2a/agents/[...slug]/route.ts - Promotion action:
packages/extensions/src/actions.ts - Instance identity / namespace:
src/lib/instance-identity-store.ts - Registry credential encryption:
packages/extensions/src/destination-resolver.ts(AAD helpers)
Where to go next
Section titled “Where to go next”- The user-facing companion: Cross-instance collaboration in the User Guide
- The open standards that frame this seam: Open standards in Cinatra
- The lifecycle for an installed cross-instance package: Extensions
- The auth + permissions model: Security