AgentRegistry is a catalog for the building blocks of agentic systems: agents, MCP
servers, and skills. arctl is its CLI, and this lab drives it through
the whole lifecycle. We scaffold all three artifact kinds with arctl
init, customize them into something real (a text-utilities MCP server, a
house-style summary skill, and a summarizer agent that uses both), prove the trio
works locally with arctl run, build the scaffolded Dockerfiles into
OCI images and push them with arctl build, and publish everything to
the catalog with arctl apply. Then the payoff: a Kubernetes
Runtime points the registry at a kind cluster, an AgentRegistry
Deployment lands the agent on Solo Enterprise for
kagent, and we test the hosted agent through the controller's
OIDC-protected A2A endpoint with a real Keycloak token. Every output below is
captured from the live run.
The use case
An agent on a laptop is a demo. An agent your team can find, reuse, and run is a platform, and that needs three things the laptop never provides: a catalog that says what exists, packaging that makes each piece reproducible, and a runtime that hosts the result behind real authentication. That is exactly the AgentRegistry split. Artifacts are scaffolded and built as OCI images, published to a catalog other people can query, and deployed by reference onto a runtime. Here the runtime is Solo Enterprise for kagent, so the moment the agent lands in the cluster it inherits the controller's OIDC enforcement: callers need a valid Keycloak token before they ever reach the model.
Three artifacts, one catalog
The lab builds one of each artifact kind, deliberately small so the wiring stays legible. The agent consumes the other two: it calls the MCP server's tools at runtime, and the skill is folded into its instruction at build time.
textkit
- what
- Two tools:
word_countandextract_links - stack
- FastMCP, Python, stdio transport
- ships as
- OCI image built from the scaffolded Dockerfile
Summary house style
- what
- A SKILL.md: length budget, bullet rules, sources line
- stack
- Markdown with YAML frontmatter
- ships as
- Git-sourced; baked into the agent image at build
summarizer
- what
- Summarizes pasted text in the house format
- stack
- ADK Python, Anthropic
claude-haiku-4-5 - uses
- textkit tools via MCP + the baked skill
The flow
Step 1: scaffold with arctl init
One command per artifact kind. Each produces a complete project: source layout,
manifest, Dockerfile, env wiring, tests. The flags pick the framework, language,
and model up front, and --local-mcp wires the agent's local dev
config at the textkit server so the pair work together from the first run.
arctl init mcp acme/textkit --framework fastmcp --language python
arctl init skill summary-style
arctl init agent summarizer --framework adk --language python \
--model-provider anthropic --model-name claude-haiku-4-5 \
--local-mcp ./textkit
What lands on disk is the part that usually takes a day of boilerplate. The MCP
project gets a FastMCP server with a dynamic tool loader and a multi-stage
Dockerfile; the agent project gets an ADK application, an A2A agent card, a
Dockerfile based on the kagent ADK base image, and a .env.example
already pointing at the local textkit; the skill gets a SKILL.md and
its catalog manifest.
textwhat arctl init generates (trimmed)
artifacts/
├── textkit/ # arctl init mcp acme/textkit
│ ├── mcp.yaml # catalog manifest (kind: MCPServer)
│ ├── Dockerfile # multi-stage python + uv build
│ ├── src/
│ │ ├── main.py # entrypoint (stdio or http transport)
│ │ ├── core/server.py # FastMCP app + dynamic tool loader
│ │ └── tools/ # one file per tool, discovered by name
│ └── tests/ # pytest discovery + server tests
├── summary-style/ # arctl init skill summary-style
│ ├── skill.yaml # catalog manifest (kind: Skill)
│ └── SKILL.md # the skill body
└── summarizer/ # arctl init agent summarizer
├── agent.yaml # catalog manifest (kind: Agent)
├── Dockerfile # FROM kagent-adk base image
├── .env.example # ANTHROPIC_API_KEY + MCP_SERVERS_CONFIG
└── summarizer/
├── agent.py # ADK root_agent definition
├── agent-card.json # A2A card
└── mcp_tools.py # MCP wiring read from env
Step 2: make the artifacts real
The scaffolds run as generated, but they are templates. Three focused edits turn them into the demo. First, the MCP server gets two real tools. The loader in the scaffold discovers a tool per file by naming convention, so each tool is just a decorated function:
pythonartifacts/textkit/src/tools/word_count.py
@mcp.tool(
description=(
"Count the words, characters, and sentences in a block of text. "
"Call this before summarizing so the summary length is proportional "
"to the input size."
)
)
def word_count(text: str) -> dict:
words = re.findall(r"\b\w+\b", text)
sentences = re.findall(r"[.!?]+(?:\s|$)", text)
return {
"words": len(words),
"characters": len(text),
"sentences": max(len(sentences), 1 if text.strip() else 0),
}
pythonartifacts/textkit/src/tools/extract_links.py
_URL_RE = re.compile(r"https?://[^\s<>\")']+")
@mcp.tool(
description=(
"Extract every http/https URL from a block of text, de-duplicated and "
"in first-seen order. Use this to list the source links for a summary."
)
)
def extract_links(text: str) -> dict:
seen: list[str] = []
for match in _URL_RE.findall(text):
url = match.rstrip(".,);]")
if url not in seen:
seen.append(url)
return {"count": len(seen), "links": seen}
Second, the skill. A skill is instructions as an artifact: versioned, published, reusable by any agent. This one defines the house summary format, including the workflow that tells the model to call the textkit tools before writing:
markdownartifacts/summary-style/SKILL.md (trimmed)
# Summary house style
## Workflow
1. Call the `word_count` tool on the input first. Aim for roughly 10% of the
input length, never more than 150 words.
2. Call the `extract_links` tool. If it returns links, list them under a
`Sources:` line at the end.
3. Write the summary in the format below.
## Format
- Open with a single bold one-line takeaway, no more than 20 words.
- Follow with 3 to 5 bullet points, one sentence each.
- End with a `Sources:` line listing the URLs, comma separated.
Third, the agent reads both. Its instruction is the role plus the skill body, and the skill ships inside the agent image: the SKILL.md is copied into the agent project before the build and a small loader folds it into the instruction at startup. That makes the published agent image self-contained, with the exact skill version it was built against:
pythonartifacts/summarizer/summarizer/agent.py (the wiring)
_ROLE = (
"You are a summarization assistant. The user gives you a block of text and "
"you return a summary. You have textkit MCP tools available: word_count and "
"extract_links. Always follow the summary house style below."
)
_INSTRUCTION = _ROLE + "\n\n" + load_baked_skills() # SKILL.md body, frontmatter stripped
root_agent = Agent(
model=LiteLlm(model="anthropic/claude-haiku-4-5"),
name="summarizer_agent",
instruction=build_instruction(_INSTRUCTION),
tools=get_mcp_tools(), # textkit, injected via env
)
Step 3: prove it locally first
Before any cluster exists, arctl run gives the inner dev loop. The
lab's test-local.sh starts the textkit MCP server on the host with
the HTTP transport, then runs the agent; arctl run builds the agent
image, starts it, waits for the endpoint, and drops you into an interactive A2A
chat. The agent's .env points MCP_SERVERS_CONFIG at the
host-side textkit, so the full agent-to-tools path is exercised with nothing but
Docker:
# terminal 1 (or let test-local.sh manage both)
cd artifacts/textkit && uv run python src/main.py --transport http --port 3000
# terminal 2: build + run the agent, interactive A2A chat
arctl run ./artifacts/summarizer
Step 4: build the images, publish to the catalog
Two of the three artifacts are container images, and arctl build
builds them with the Dockerfiles the scaffold generated. The scaffolds default
their image identifiers to localhost:5001/..., which is exactly the
local OCI registry the lab runs next to kind, so --push needs no
extra configuration. The skill is git-sourced and has no image.
arctl build ./artifacts/textkit --push # localhost:5001/textkit:latest
arctl build ./artifacts/summarizer --push # localhost:5001/summarizer:latest
The textkit Dockerfile is the scaffold's multi-stage Python build: a builder layer resolves dependencies with uv, the production layer runs as a non-root user with a healthcheck. The agent's Dockerfile is even shorter because the heavy lifting lives in the kagent ADK base image:
dockerfileartifacts/summarizer/Dockerfile (generated by arctl)
ARG DOCKER_REGISTRY=ghcr.io
ARG VERSION=0.8.0-beta6
FROM $DOCKER_REGISTRY/kagent-dev/kagent/kagent-adk:$VERSION
WORKDIR /app
COPY summarizer/ summarizer/
COPY pyproject.toml pyproject.toml
COPY README.md README.md
COPY .python-version .python-version
RUN uv sync
ENV OTEL_SERVICE_NAME=summarizer
CMD ["summarizer"]
dockerfileartifacts/textkit/Dockerfile (generated by arctl, trimmed)
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml .python-version* uv.lock* README.md ./
RUN uv sync --frozen --no-dev --no-cache --no-install-project
COPY src/ ./src/
COPY mcp.yaml ./
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
RUN groupadd -r mcpuser && useradd -r -g mcpuser mcpuser
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/src /app/src
COPY --from=builder /app/mcp.yaml /app/mcp.yaml
ENV PATH="/app/.venv/bin:$PATH" PYTHONPATH=/app PYTHONUNBUFFERED=1
USER mcpuser
# ENTRYPOINT (not CMD) so transport args append instead of replacing
ENTRYPOINT ["python", "src/main.py"]
Publishing is arctl apply on each manifest, against the local
AgentRegistry daemon (Docker Compose, API and UI on
localhost:12121). Order matters once: the MCPServer must exist before
the Agent that references it, because the registry resolves
spec.mcpServers at apply time.
arctl apply -f artifacts/textkit/mcp.yaml # MCPServer acme/textkit
arctl apply -f artifacts/summary-style/skill.yaml # Skill summary-style
arctl apply -f artifacts/summarizer/agent.yaml # Agent summarizer
yamlthe three catalog manifests (trimmed)
apiVersion: ar.dev/v1alpha1
kind: MCPServer
metadata:
name: acme/textkit
spec:
title: textkit
description: Text utilities for summarization agents.
source:
package:
identifier: localhost:5001/textkit:latest
registryType: oci
transport: { type: stdio }
---
apiVersion: ar.dev/v1alpha1
kind: Skill
metadata:
name: summary-style
spec:
title: Summary house style
description: The house format for summaries.
source:
repository:
url: https://github.com/tjorourke/solo-labs.git
branch: main
subfolder: agentregistry-arctl-kind/artifacts/summary-style
---
apiVersion: ar.dev/v1alpha1
kind: Agent
metadata:
name: summarizer
spec:
description: Summarizes pasted text in the house format, using textkit MCP tools.
modelName: claude-haiku-4-5
modelProvider: anthropic
source:
image: localhost:5001/summarizer:latest
mcpServers:
- kind: MCPServer
name: acme/textkit
Once published, the catalog is the shared source of truth: anyone pointed at the registry can browse what exists, who it belongs to, and where it came from. Note the provenance built into the artifacts themselves. The MCP server and agent resolve to OCI images by digestable identifier, and the skill links straight back to its source repository, branch, and subfolder, so a catalog entry is traceable to code:
$ arctl get mcp acme/textkit && arctl get skill summary-style && arctl get agent summarizer
NAME TAG DESCRIPTION
acme/textkit latest Text utilities for summarization agents — word_count an...
NAME TAG DESCRIPTION
summary-style latest The house format for summaries — length budget, bullets...
NAME TAG PROVIDER MODEL
summarizer latest anthropic claude-haiku-4-5
Step 5: point the registry at the cluster
A Runtime is the registry's pointer to somewhere agents can run. For
Kubernetes it is a namespace plus a kubeconfig. There is one genuinely fiddly bit
on a laptop: the daemon runs in Docker, so 127.0.0.1 in a normal
kubeconfig means the daemon's own container. The lab joins the daemon to the kind
Docker network and feeds the Runtime the cluster's internal kubeconfig
(kind get kubeconfig --internal), whose server address is the
control-plane container's hostname and whose TLS SAN actually matches it.
yamlthe Runtime (kubeconfig filled in by the script)
apiVersion: ar.dev/v1alpha1
kind: Runtime
metadata:
name: kind-kagent
spec:
type: Kubernetes
config:
namespace: kagent
kubeconfig: |
# `kind get kubeconfig --internal` output: the server is the
# control-plane container hostname, reachable from the daemon
# over the kind docker network
Step 6: deploy the agent onto kagent
Deployment is also a registry resource: it binds an Agent to a Runtime and carries
per-instance environment. Apply it with arctl and the registry's
Kubernetes adapter translates it into kagent resources in the target namespace:
yamlthe Deployment that hosts summarizer on the kind Runtime
apiVersion: ar.dev/v1alpha1
kind: Deployment
metadata:
name: summarizer
spec:
targetRef: { kind: Agent, name: summarizer }
runtimeRef: { kind: Runtime, name: kind-kagent }
env:
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY}" # the agent's model key
# MCP wiring is injected by the registry from the Agent's spec.mcpServers
What lands in the cluster is the part worth staring at. The registry creates a
kagent Agent of type BYO for the summarizer image and a
kmcp MCPServer for textkit, and the kagent controller takes it
from there: pods are scheduled, images pulled from localhost:5001
(the nodes are wired to resolve it), and the agent is registered on the
controller's A2A surface.
$ kubectl -n kagent get agents,pods
NAME TYPE READY ACCEPTED
agent.kagent.dev/summarizer-latest-summarizer BYO True True
NAME READY STATUS
pod/summarizer-latest-summarizer-9748fdccc-k7rp2 1/1 Running
pod/acme-textkit-summarizer-5689d64f47-svrst 1/1 Running # the MCP
pod/kagent-controller-bbd97cbf7-r29wq 1/1 Running
spec.deployment.cmd to python src/main.py
after the resource appears.
Step 7: test it through kagent
The hosted agent sits behind Solo Enterprise for kagent's OIDC interceptor, so the
test is honest: mint a real token for alice from the in-cluster
Keycloak, then call the agent over the controller's A2A endpoint. Alice is in
group field-fte, which the install maps to the kagent Admin role via
the token's groups claim, so she may invoke agents. The lab's
ask.sh does both steps with plain curl:
./scripts/ask.sh "summarize this: <paste a paragraph with a couple of links>"
bashwhat ask.sh does (token + A2A message/send by hand)
# 1. mint alice's token from the in-cluster Keycloak (realm solo)
TOKEN=$(curl -s -X POST \
"http://localhost:18080/realms/solo/protocol/openid-connect/token" \
-d "grant_type=password&client_id=kagent&username=alice&password=alice" \
| jq -r .access_token)
# 2. send the task over A2A (trailing slash on the agent path matters:
# without it the controller 307-redirects and drops the POST body)
curl -X POST "http://localhost:8083/api/a2a/kagent/summarizer-latest-summarizer/" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":"1","method":"message/send",
"params":{"message":{"role":"user","messageId":"m1",
"parts":[{"kind":"text","text":"summarize this: ..."}]}}}'
The claims in alice's token, and the reply that came back from the hosted agent, both exactly as captured from the live cluster:
jsonalice's token claims (decoded, trimmed)
{
"iss": "http://keycloak.keycloak.svc.cluster.local/realms/solo",
"aud": "kagent",
"preferred_username": "alice",
"groups": ["field-fte"],
"email": "alice@example.com"
}
summarize this: AgentRegistry is an open catalog for AI agents, MCP servers, skills and prompts. The arctl CLI scaffolds a new artifact from a template, builds it into an OCI image, and publishes it to a registry so other people can discover and reuse it. The registry daemon exposes an API and a web UI on port 12121. A Kubernetes Runtime adapter translates a Deployment resource into kagent CRDs, which means a published agent can be hosted on Solo Enterprise for kagent with OIDC authentication enforced in front of it. Docs live at https://aregistry.ai and the source is at https://github.com/agentregistry-dev/agentregistry. The project is Apache 2 licensed.
AgentRegistry is an open catalog for discovering and reusing AI agents and MCP artifacts
• The arctl CLI creates new artifacts from templates, packages them as OCI images, and publishes them to the registry for discovery and reuse
• A registry daemon provides API and web UI access on port 12121
• Kubernetes Runtime adapters enable published agents to run on Solo Enterprise with OIDC authentication enforcement
• The project is Apache 2 licensed and available open source
Sources: https://aregistry.ai, https://github.com/agentregistry-dev/agentregistry
Look at what that one reply proves. The bold headline and bullet format come from
the skill, the proportional length comes from the
word_count MCP tool, the Sources line comes from
extract_links, and the whole thing only ran because alice's
Keycloak token passed the controller's OIDC check. Every artifact
in the catalog, and the runtime in front of them, in a single request.
Sharp edges worth knowing
- The standalone daemon needs two env vars.
DOCKER_REPO=solo-public/agentregistry-enterprisepoints the compose file at the public image mirror, andOIDC_AUTO_AUTH_ENABLED=trueswitches on the embedded auto-auth IdP so no external Keycloak is needed for the registry itself. The lab then mints its admin bearer from/api/autoauth/oauth/token. - Role mapper claim case. The kagent install maps roles with
claims.groups(lowercase). Keycloak emitsgroups, and a capitalized claim name in the CEL expression fails the mapping and returns 401 for every caller. - Trailing slash on the A2A path. POST to
/api/a2a/<ns>/<agent>/; without the slash the controller answers 307 and the body is gone. - The reply lives in result.artifacts. A2A
message/sendreturns a Task whose history includes the user message and the agent's working notes; the final answer is the artifacts list, which is whatask.shprints. - Agent names get suffixed. The registry names the kagent
Agent from the artifact, tag, and Deployment name combined
(
summarizer-latest-summarizerhere), so scripts resolve it by prefix instead of hardcoding.
Run it yourself
Everything in this lab, the scripts, the kind config, the YAML, and the three scaffolded artifact projects, is in the public solo-labs repo, so you can clone and run it directly. One kind cluster, one local OCI registry, the AgentRegistry daemon in Docker, and Solo Enterprise for kagent with Keycloak in front. Bring an Anthropic key for the agent's model and a Solo license for the enterprise kagent install:
git clone https://github.com/tjorourke/solo-labs.git
cd solo-labs/agentregistry-arctl-kind
export ANTHROPIC_API_KEY=sk-ant-...
export SOLO_LICENSE_KEY=... # Solo Enterprise for kagent
./scripts/quick.sh up # cluster → keycloak → kagent → daemon
# → scaffold check → build/publish → deploy
./scripts/ask.sh "summarize this: "
./scripts/test-local.sh # the no-cluster inner loop (arctl run)
./scripts/quick.sh status # catalog + cluster at a glance
./scripts/quick.sh teardown
Where to take it next
This lab is the paved road: scaffold, publish, deploy, invoke. The enterprise registry is built for the governance jobs that start the moment a second team shows up, and that is exactly where the series goes. Part 2 is live: it puts the same Keycloak realm in front of the registry itself and works through role mapping, AccessPolicies, and per-team catalog visibility on this same cluster. The full list of natural follow-ons:
- Approval flows. With approval enabled, a non-admin publish lands in a pending state and only an admin review makes it visible and deployable. The same flow governs agents, MCP servers, and skills.
- Visibility partitioning. AccessPolicies grant
registry:readandregistry:publishper team and per resource, down to name patterns. Team A'sarctl get agentsonly returns Team A's agents; everything else is absent, not just denied. - Real IdP in front of the registry. This lab runs the daemon with its embedded auto-auth IdP to stay standalone. The enterprise install wires the registry's own OIDC at your IdP, with claim-to-role mapping, the same way this lab already wires kagent at Keycloak.
- Multiple runtimes, one catalog entry. The sibling AgentCore lab deploys one catalog Agent to in-cluster kagent and AWS Bedrock AgentCore by switching only the Deployment's runtimeRef.
- Agentic traces and token cost. The scaffolds ship with
OpenTelemetry wiring (note the
OTEL_SERVICE_NAMEin both generated Dockerfiles), so the loop from user prompt to LLM call to tool invocation can be traced span by span and exported to an existing observability stack. - Gateway-governed MCP. Put agentgateway in front of the MCP servers the catalog tracks and enforce per-tool, per-user policy at the data plane; the tool curation lab shows that pattern end to end.
See also
- Part 2: one realm, two teams, a policy-partitioned catalog
- AgentRegistry docs
- AgentRegistry source
- agentregistry CRDs: a visual map
- Sibling lab: one Agent in AgentRegistry deployed to kagent and AWS Bedrock AgentCore
- Sibling lab: agent-to-agent delegation in kagent over A2A
Versions
Built and verified on:
0.4.3v1.4.0