An OGX Responses API demo that wires together a remote MCP server and a
pizza-topping skill.
Everything below was verified against OGX 1.3.1 and openai-python 3.8.0, running the server locally and calling it.
Does the MCP connection belong on the OGXServer or in the Responses API?
In the Responses API, per request. The builtin responses provider imports
list_mcp_tools / invoke_mcp_tool directly from ogx.providers.utils.tools.mcp
and dials the MCP endpoint itself — it does not route through the
remote::model-context-protocol tool_runtime provider. That provider exists for
the older tool_groups/Agents path. So there is nothing to register server-side:
put the MCP server in the request's tools array and the credentials stay with
the caller.
How does an OGXServer "mount" a SKILL.md?
With the builtin skills provider, it can't — a skill is a runtime API object,
not a file on disk. BuiltinSkillsImpl.initialize() is a no-op, its config has
only a persistence key, and RegisteredResources (models, vector_stores,
tool_groups) has no skills entry, so there is nothing declarative to hook. The
only way in is to POST a zip to /v1alpha/skills after the server is up.
So use a different provider. ogx_skills_fs/ is a small out-of-tree skills
provider that reads skills off the filesystem, which makes the skill part of
the manifest — no Job, no initContainer, no client-side registration. See
Serving skills from the filesystem;
deploy/ogxserver.yaml is the whole deployment.
These all cost real debugging time, so they're listed with what actually happens.
ogx go cannot run this demo. It auto-detects providers and hardcodes
responses + tool_runtime into the API list, but never skills. Against the
ogx go server, /v1alpha/skills returns 404. Use ogx run run.yaml.
skills depends on the files API. The provider spec declares
api_dependencies=[Api.files] — bundle blobs are stored through the Files API.
Enabling skills without files fails to start.
SKILL.md must be at the zip root. OGX checks entry.filename == "SKILL.md".
OpenAI's hosted Skills API expects a single top-level folder containing
SKILL.md; do that here and you get
Failed to validate skill bundle: SKILL.md not found at archive root.
Frontmatter must also include name.
client.skills.* in the OpenAI SDK does not work against OGX 1.3.1. The SDK
POSTs to /v1/skills with a multipart field named files. OGX serves
/v1alpha/skills with a field named file. Both the path and the field name
differ, so app.py uses raw httpx.
skills needs extra_body. It's a top-level field on OGX's
CreateResponseRequest, but responses.create() has no such kwarg, so pass
extra_body={"skills": [skill_id]}.
The MCP token goes in authorization, not headers. Pass the bare token —
OGX prepends Bearer . Putting an Authorization key in headers is rejected:
400 ... Authorization credentials must be passed via the 'authorization' parameter, not 'headers'. The field is declared exclude=True, so the token is
not persisted onto the stored response object.
OGX skills are unconditional context injection, and weak models over-apply
them. Every ID in skills has its SKILL.md concatenated into a system message
on every request — there is no model-driven decision about whether the skill is
relevant, unlike Claude's progressive disclosure. With gpt-4o-mini, the pizza
policy hijacked "What is 2+2?". gpt-4.1-mini and gpt-4o both gate it
correctly, which is why run.yaml uses gpt-4.1-mini. The SKILL.md also needs
an explicit "when this does NOT apply" section.
ogx run --dry-run cannot validate module: external providers. Validation
takes the "listing" code path, which never imports the module, so it fails with
Provider 'inline::filesystem' is not available for API 'Api.skills' on a
config the real server starts from happily. Don't trust --dry-run as a gate
for external providers — start the server and check GET /v1/providers.
Changing provider_model_id for an existing model_id fails at startup.
The registry is persisted in the KV store, so re-pointing demo-model from
gpt-4o-mini to gpt-4.1-mini gives
Object of type 'model' and identifier 'openai/demo-model' already exists with conflicting field. Use a fresh OGX_STORAGE_DIR, or a new model_id. That's
why run.yaml defaults to ~/.ogx/wn-responses-fs rather than
~/.ogx/wn-responses — the latter may still hold the gpt-4o-mini binding.
Skills are inherited across previous_response_id. If a follow-up omits
skills, the previous response's skills carry over
(effective_skills = request.skills if request.skills is not None else inherited_skills).
Pass skills: [] to deliberately drop them.
export OPENAI_API_KEY=...
PYTHONPATH=. uv run ogx run run.yaml --insecure # NOT `ogx go`
export OGX_MCP_TOKEN=<bearer token>
uv run python app.pyPYTHONPATH=. is what makes module: ogx_skills_fs importable, and running
from the repo root is what makes the config's default skills_dir: ./skills
resolve. Override it with OGX_SKILLS_DIR if you run from elsewhere.
app.py uploads nothing — the skill is already there — it looks the ID up by
name, then asks two questions: one the skill answers, one the MCP server
answers. Verified output, against a local MCP server standing in for the ROSA
one:
using skill skill-pizza-topping (version 1)
> What topping should I put on my pizza?
[mcp] rhoai exposes: get_cluster_name
any toppings will do, but Pineapple is forbidded as Matteo said so.
> What tools do you have available, and what can you tell me about the cluster?
[mcp] rhoai exposes: get_cluster_name
[mcp] called get_cluster_name -> rosa-mmortari-cluster-42
I have a tool that can tell us the name of the current cluster. The name of the
cluster we're currently working with is "rosa-mmortari-cluster-42". [...]
ogx_skills_fs/ is a ~150-line out-of-tree skills provider that serves skills
from a mounted directory instead of from uploads. With it, the skill exists as
soon as the server starts — no Job, no initContainer, no client registration.
It hangs off module: in the run config. When a provider entry carries one, the
resolver imports <module>.provider, calls get_provider_spec(), and uses the
returned spec — so an external provider needs a provider.py at the package
root and a get_provider_impl(config, deps):
skills:
- provider_id: filesystem
provider_type: inline::filesystem
module: ogx_skills_fs
config:
skills_dir: /skillsEach immediate subdirectory of skills_dir holding a SKILL.md becomes one
skill. Two properties fall out of this that the builtin provider doesn't have:
- Skill IDs are deterministic —
skills/pizza-topping/becomesskill-pizza-topping, so clients can hardcode the ID instead of discovering it by listing. With the upload path you only learn the UUID at runtime. - Editing SKILL.md takes effect without a restart, because the directory is rescanned per call. Verified live: editing the file mid-run changed the next response.
Writes are rejected with 501 (create_skill, delete_skill, …); the mounted
directory is the source of truth. Unknown IDs give the usual 400.
This is the only provider run.yaml configures, so
curl -s localhost:8321/v1alpha/skills returns skill-pizza-topping on a
freshly started server, with nothing having uploaded it. To go back to the
stock behaviour, swap that one block for
provider_type: inline::builtin with a persistence config.
Two caveats. The provider imports parse_skill_manifest from
ogx.providers.inline.skills.builtin.manifest to avoid reimplementing
frontmatter parsing — that's an internal path that could move between OGX
releases. And a ConfigMap mount is not a plain directory: it contains a ..data
symlink to a timestamped directory plus one symlink per key, so a naive
rglob("*") yields every file twice (['..2026_09_07_120000.0/SKILL.md', 'SKILL.md'] — measured against a simulated mount). _is_hidden() filters
those, so the bundle ends up with exactly one SKILL.md at the root.
Install the ogx-k8s-operator, set the API key, apply one file:
kubectl apply -f deploy/ogxserver.yaml
kubectl edit secret ogx-demo-secrets # OPENAI_API_KEY, ships as "replace-me"That file carries the Secret, the provider code, the skill, the run config and the CR. Nothing runs after the apply — the skill is there as soon as the pod is ready. It also drops the skills-on-PVC requirement, since the skill comes from the ConfigMap and survives restarts and rescheduling for free.
The MCP bearer token is deliberately not in the server's config — it belongs to whatever calls the Responses API. Only the client needs it.
The ConfigMap-on-PYTHONPATH trick here is a workaround, not the operator's
recommendation. The operator has a design for this — specs/001-deploy-time- providers-l1/ — and it is wheels, shipped in an OCI provider image:
FR-002: provider images MUST contain Python wheels in/lls-provider/packages/FR-001: plus a metadata file at/lls-provider/lls-provider-spec.yamlFR-004/NFR-001: wheels MUST vendor all dependencies — the installer has no networkFR-005: the package MUST exposeget_provider_spec()(which ogx_skills_fs/provider.py already does)
An initContainer would pip install those wheels into a shared emptyDir and
prepend it to PYTHONPATH; a PVC for this was explicitly rejected in the
spec as needless complexity.
The catch: it isn't built yet. The spec is marked Status: Draft, its
tasks.md checkboxes are all unchecked, and there is no externalProviders
field in api/v1beta1 or in the CRD YAML — grep -rn "ExternalProvider" api/ pkg/ controllers/ returns nothing. So today the choice is really:
| Works today | Effort | Use for | |
|---|---|---|---|
ConfigMap on PYTHONPATH |
yes | none | demos, iteration |
| Wheel baked into a custom image | yes | build + registry | production |
| Wheel in an OCI provider image | no — unimplemented | — | the future |
For anything real, bake the wheel into the image
(FROM ogxai/distribution-starter + pip install ogx_skills_fs-*.whl) and drop
the PYTHONPATH env and the code ConfigMap from the CR. Building it as a proper
wheel now is also what makes the migration to externalProviders a repackaging
job rather than a rewrite. The ConfigMap route additionally inherits the 1 MiB
etcd object limit and can't carry compiled dependencies.
Uploading the skill from an initContainer was the obvious alternative to a Job. It doesn't work, for two independent reasons — which is what pushed this repo to the filesystem provider instead.
A plain initContainer must run to completion before the main container starts,
so there is no server to upload to yet — it would poll until timeout. A native
sidecar (restartPolicy: Always, k8s ≥1.29) would be logically fine, since
those start first but run concurrently.
Except the CRD can't express either. WorkloadOverrides is exactly
ServiceAccountName, Env, Command, Args, Volumes, VolumeMounts — no
InitContainers, no Containers, no Lifecycle, so no sidecar and no
postStart hook. grep -rn InitContainers api/ in the operator returns
nothing.
Patching the Deployment out-of-band isn't durable either: ApplyResources
switches from server-side apply to a full Update whenever
deploymentNeedsFullReplacement trips (legacy CA-bundle volumes, a stale
user-config volume, a strategy change), and there's a test asserting
"legacy init containers should be removed". It may survive a plain SSA
reconcile and then vanish on the next storage change.
Note that the operator does intend to grow initContainers — the external-
provider spec is built on them — but for installing provider wheels, not for
seeding data. Today no non-test Go file in the operator so much as mentions
InitContainers.
The local stack, the MCP wiring and the filesystem skills provider were all run
and verified against a live server. deploy/ogxserver.yaml was not applied
to a live cluster — I have no cluster access here — so treat it as
reviewed-but-unrun. What narrows the gap: the provider code and SKILL.md
embedded in it were diffed against the repo files that the local run exercised
and are byte-identical, and the config it carries is the same shape as the
run.yaml the server actually started from.
The ROSA MCP endpoint itself is also unverified — probing it returns 401 and I was never given a token — so the MCP results above come from a local stand-in server that speaks the same streamable-HTTP transport.